7.4 KiB
The ServerKit notification delivery contract
One page every producer and plugin author reads before calling
notify.send(). It defines who hears about an event, on which channel, and
when. The code cites this file as the source of truth — when the code and this
document disagree, it's a bug in one of them, so fix both together.
This document is tracked (committed). Planning and status notes live in the git-ignored
docs/NOTIFICATIONS_ROADMAP.md; the resolution/digest rules that the bus actually enforces live here.
Preference resolution order
For a given (user, event, channel), the bus (app/notifications/service.py,
_plan() → _resolve_channels()) resolves "does this channel fire?" as a
two-stage decision.
Stage A — does the event reach this user at all?
Evaluated top-down; the first rule that applies wins:
- Account-wide kill switch —
NotificationPreferences.enabled == Falsestops everything, includingcriticaland a per-event override. Nothing below runs. - Per-event force-ON override — if
events_json[event_key]turns any channel ON, the event is resurrected: it reaches the user even when the category opt-out or severity list below would exclude it. A per-event override is the most specific preference a user can express, so it wins over the coarser category/severity gates (plan 33 Decision 1). A pure force-OFF map (all channelsfalse) is not a resurrect — it only trims channels the normal path would have fired. - Category opt-out —
categories[event.category] == Falsestops the event, including a critical (only a force-ON override at rule 2, or the event not being opted out, lets it through). - Severity gate — the event's severity must be in
severities.criticalalways passes this gate.
If Stage A lets the event through, Stage B decides the channel set.
Stage B — which channels fire?
Per channel, first match wins (_resolve_channels()):
- User per-event override —
events_json[event_key][channel](true/false). Explicit on/off for this event + channel. - User channel selection —
channels(the in-app bell is always on for an enabled user, independent of this list). - Org default floor —
notify.defaults[category][channel](admin-set): a per-category channel floor, e.g. "backups → email off for everyone". - Catalog default — on if the channel is user-selected.
Hard invariant: severity == 'critical' reaches an enabled, non-category-
opted-out user on every configured channel — it pierces the severity gate,
quiet hours, the org floor, and a per-event force-OFF. Only the account-wide
enabled=False (Stage A rule 1) or a category opt-out (Stage A rule 3) stops a
critical.
Quiet hours
Quiet hours delay non-critical notifications; they never delete them (plan 33 Decision 3).
- Email — a non-critical email suppressed by quiet hours is written
queued_digestand flushed by the hourly digest job once the user is past their quiet window (its catch-up; see below). - Chat (Discord / Slack / Telegram / webhook) — a non-critical chat delivery
suppressed by quiet hours is written
queued_digesttoo, then flushed as one compact per-connection summary when the window ends. No per-item replay spam: one summary per destination per quiet window. - In-app — always recorded immediately (the bell is silent), so there's a catch-up record when the user returns.
- Critical — pierces quiet hours on every channel, immediately.
Digest severity rules
Digesting batches low-value events into one branded email instead of N sends. Per severity, the default routing is:
| Severity | In-app | |
|---|---|---|
info / success |
immediate | digested |
warning |
immediate | digested |
critical |
immediate | immediate (never digested) |
- Users pick a digest cadence (
daily|weekly|off) in their prefs.off(default) = the pre-digest behavior (email sends immediately). - A digestable email delivery is written
queued_digest; the consumer skips it; the hourlynotifications.digest.runjob groups a user'squeued_digestrows, renders onedigest.htmlgrouped by category, sends it, and marks the rowssentwith the digest's provider message-id. - UX guards: an empty digest never sends; a single-item digest uses that item's own subject line (no "1 update" shame mail).
Deep links
Every notification carries an optional action_path (+ action_label),
computed at send time (catalog link builder or a producer override) and
persisted on the Notification row so a later route change never breaks old
rows. Bell, history, and the email CTA all consume the same field.
Deep links follow a fragment contract (plan 33 Decision 2): a link is a bare
page path plus an optional ?focus=<kind>:<id> query param
(e.g. /backups?focus=policy:12, /domains?focus=domain:example.com,
/cron?focus=job:job_2026...). The destination page reads focus once on mount
and opens its existing drawer / panel / modal, then clears the param so a refresh
doesn't re-trigger.
Per-user vs org chat targeting
Both apply and are additive, deduped per destination (plan 33 Decision 5):
- Per-user chat —
_target_forresolves a user's personal Discord webhook / Telegram chat id from their prefs; delivered per recipient. - Org broadcast — every active
ChatWebhookConnectionwhose category filter matches receives the event once (conn:<id>target), independent of the per-user recipients.
A (user_id, channel, target) / (None, kind, conn:<id>) de-dupe key prevents a
re-plan from double-sending to the same destination.
Bounce / complaint handling
Provider bounce/complaint webhooks post to
POST /api/v1/notifications/inbound/email (HMAC-signed; 404 when unconfigured).
A hard bounce or complaint is recorded against the delivery's stored
provider message-id; after N consecutive hard bounces an address is
auto-muted (with an unmute affordance in the user's notification settings) and
admins are notified (plan 33 Decision 4 / roadmap #24).
Pointing a provider's bounce webhook at ServerKit
- Set the shared secret. Store a random secret under the SettingsService
key
notify.inbound_secret(until it's set, the endpoint 404s — the feature is off by default). Any panel process reading the same DB sees it. - Configure the provider webhook to
POSTbounce/complaint events tohttps://<your-panel>/api/v1/notifications/inbound/email, signing the raw request body with HMAC-SHA256 using the secret and sending the hex digest in anX-ServerKit-Signature: sha256=<hex>header. (If your provider signs with its own scheme, put a tiny signing proxy in front, or extend the one adapter seam.) - Payload shapes are mapped provider-agnostically — SendGrid event arrays,
Postmark, Amazon SES (raw or SNS-wrapped), Mailgun, and a generic
{message_id, kind, reason, email}all work. Soft/transient failures are ignored; only hard bounces and spam complaints count toward the mute. - Correlation is by the provider message-id captured at send time; when a payload also carries the recipient address, that's used as a fallback.
Throttling
Throttling stays code-side at the producer (edge-triggered / first-failure + recovery, per the doctor/health precedents). The bus adds no generic cooldown engine.