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.
| Table | Purpose |
|---|---|
notifications | Per-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_deliveries | Per-channel attempt log. One row per (notification, channel). Status moves `pending → sent |
user_devices | Push 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.
| Channel | Description |
|---|---|
in_app | Inbox row in the dashboard at /dashboard/notifications, with an unread badge on the user-menu avatar (polled every 30s). Always written synchronously. |
email | HTML email via Resend (or console logger in dev). Per-type formatters (sendIssueDigest, sendJobAlert) plus a generic fallback. |
ios_push | APNs 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.
| Type | Trigger | Channels | Default On |
|---|---|---|---|
issue.digest | Per-project periodic digest of new and regressed issues, sent by the issue_notify job at the project's configured frequency. | in_app, email, ios_push | All three |
feedback.new | A user submitted feedback in one of the team's apps. | in_app, email, ios_push | All three |
job.completed | A manual job run with --notify finished — only the triggering user is notified, never the whole team. | in_app, email, ios_push | in_app, email (push off by default) |
team.invitation | Listed 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/mereturns the merged preferences blob.PATCH /v1/auth/meshallow-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 theissue_notifyjob 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.sendVerificationCodestraight from the/v1/auth/send-coderoute. - Team invitations — sent via
EmailService.sendTeamInvitationfrom 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 perpendingrow innotification_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.
Related Resources
/dashboard/notifications— inbox UI/dashboard/profile/notifications— per-channel preferences- Notifications API — REST reference
- Devices API — push token registry REST reference
owlmetry notifications— CLI reference- Issues —
issue.digestproducer + per-project alert frequency - Feedback —
feedback.newproducer - Background Jobs —
job.completedproducer when triggered with--notify
