Owlmetry

Notifications

Multi-channel notification system — per-user inbox, email, and iOS push fan-out from a single producer call.

Notifications are the unified path for telling users something happened — a new feedback item arrived, an issue digest is ready, a manual job they triggered finished. Producers (the issue-scan job, feedback ingest, manual job runs) write a single record; the dispatcher fans it out across whichever channels each user has enabled.

Three Primitives

The system is backed by three tables. Keeping them separate means a failed email retry doesn't touch the inbox row, and "did the iOS push actually send?" is answerable without grepping a status grab-bag.

TablePurpose
notificationsPer-user inbox row. One per recipient, written synchronously on enqueue. Carries the pre-rendered title, body, optional deep link, structured data, read_at, deleted_at.
notification_deliveriesPer-channel attempt log. One row per (notification, channel). Status moves `pending → sent
user_devicesPush token registry. Channel-tagged so iOS APNs today and FCM/Telegram chat IDs/webhook URLs in the future share one schema. Keyed by token (unique) so Apple reissuing the same token to a different user atomically reassigns ownership.

Channels

Notifications fan out across channels. The current set is in_app, email, and ios_push; adding Telegram, Android push, or Slack means dropping in a new ChannelAdapter and extending NOTIFICATION_CHANNELS — no producer change.

ChannelDescription
in_appInbox row in the dashboard at /dashboard/notifications, with an unread badge on the user-menu avatar (polled every 30s). Always written synchronously.
emailHTML email via Resend (or console logger in dev). Per-type formatters (sendIssueDigest, sendJobAlert) plus a generic fallback.
ios_pushAPNs HTTP/2 to every registered iOS device. Unread count is computed and sent as the badge. Token-based auth with an Apple .p8 key — see APNs configuration for env vars.

Notification Types

Each notification has a type that determines its formatters and which channels users can toggle.

TypeTriggerChannelsDefault On
issue.digestPer-project periodic digest of new and regressed issues, sent by the issue_notify job at the project's configured frequency.in_app, email, ios_pushAll three
feedback.newA user submitted feedback in one of the team's apps.in_app, email, ios_pushAll three
job.completedA manual job run with --notify finished — only the triggering user is notified, never the whole team.in_app, email, ios_pushin_app, email (push off by default)
team.invitationListed for documentation but not routed through the dispatcher. Sent transactionally via EmailService directly because the recipient may not yet be a user.

Adding a new type is three steps: append to NOTIFICATION_TYPES, add an entry to NOTIFICATION_TYPE_META with default channel state, and wire a producer call site to dispatcher.enqueue(...).

Per-User Preferences

Channel preferences live under users.preferences.notifications.types as a sparse override map: missing entries fall back to the type's defaults from NOTIFICATION_TYPE_META. Read and update via the standard user-prefs endpoints:

  • GET /v1/auth/me returns the merged preferences blob.
  • PATCH /v1/auth/me shallow-merges the top level and deep-replaces nested objects, so two browser tabs editing different sub-objects don't clobber each other.

The dashboard's profile page at /dashboard/profile/notifications renders one row per type per channel and PATCHes the override.

Per-Project Alert Frequency vs Per-User Channels

Two knobs cooperate:

  • Per-project issue_alert_frequency (none, hourly, 6_hourly, daily, weekly) is the rate-limit / batching policy — how often the issue_notify job assembles a digest for that project. It applies to all team members.
  • Per-user channel toggles decide where the digest goes for each recipient — inbox only, plus email, plus iOS push, etc.

A project on daily digests with three team members where one disables email and another disables push will send three digests at the same hour, each over the channels the recipient has enabled.

System-Job Alerts Stay Direct

System job failures (db_pruning, partition_creation, attachment_cleanup, app_version_sync, etc.) are a server-owner concern, not a team-member concern. They keep going to SYSTEM_JOBS_ALERT_EMAIL via the direct email path and never enter the dispatcher, the inbox, or push. There's intentionally no system.alert notification type.

Transactional Email Stays Direct Too

Two flows bypass the dispatcher because the recipient may not yet have a users row:

  • Verification codes — sent via EmailService.sendVerificationCode straight from the /v1/auth/send-code route.
  • Team invitations — sent via EmailService.sendTeamInvitation from the team-invite flow.

Both reach the email channel directly and are never written to a notifications inbox.

APNs Setup

iOS pushes use token-based auth with an ES256-signed JWT (the same EC P-256 pattern Apple uses for App Store Connect API, Apple Search Ads, and Sign in with Apple). One Apple Developer account → one set of env vars covers the whole server. Configure them at APNS_KEY_ID, APNS_TEAM_ID, APNS_KEY_P8, APNS_BUNDLE_ID, and APNS_ENV — see Self-hosting configuration.

If APNS_KEY_P8 is unset the iOS push adapter logs once at boot and marks every push delivery skipped, so dev / local environments work without push setup.

The adapter sends sandbox-environment pushes to api.sandbox.push.apple.com and production pushes to api.push.apple.com. APNs replies of 410 Unregistered or 400 BadDeviceToken hard-delete the offending user_devices row so the server stops retrying — the next time the device opens the app it will register a fresh token.

Background Jobs

Two jobs back the system:

  • notification_deliver — one job per pending row in notification_deliveries. The handler loads the row, runs the matching channel adapter, and updates the delivery's status. See Background Jobs.
  • notification_cleanup — daily at 6am UTC. Soft-deletes read notifications older than 30 days, hard-deletes soft-deleted notifications older than 90 days.

Permissions

Notifications and devices are user-scoped, not team-scoped. The REST routes accept JWT cookies only — agent API keys receive 403. There is intentionally no MCP tool surface for notifications: an agent has no inbox.

Ready to get started?

Connect your agent via MCP or CLI and start tracking.