32 KiB
Push notifications
How push notifications work in this project: the storage and registration model, the delivery pipeline, the Web Push browser client, the notification settings UI, and how a native (Capacitor) client integrates through the same API.
The backend (all three transports — APNs / FCM / Web Push), the
device-registration API, device management, the Web Push browser/PWA client and
the native (Capacitor) iOS/Android client are implemented. The native client
(src/frontend/src/features/native/push.ts) registers through the same
POST /users/me/channels/ and is served by the same senders; the per-platform
project config (APNs entitlement, loc-key strings, Android permission,
conditional google-services) ships in the ios/ / android/ projects, and
operators supply the per-instance credentials (§3, §12).
1. Storage & registration
- A device is a user-scoped
Channel(type="push"). The opaque token lives encrypted inencrypted_settings.token(+keysfor Web Push);settingsholdsplatformandapp_version; the dedup/reclaim key is the indexed, globally-uniqueChannel.lookup_hashcolumn (sha256 of thepush:-prefixed token). - Register / refresh:
POST /api/v1.0/users/me/channels/with{type: "push", platform, token, keys?, name?, app_version?}. The collection POST routestype=pushto an idempotent upsert (201 first time, 200 on refresh); reclaims a token from another user on account switch. 404 whenPUSH_ENABLEDis off. Throttled per user. - List / revoke: the normal
GET/DELETE /api/v1.0/users/me/channels/. Push channels are blocked from create/PATCH through the generic endpoint. platformis a transport, not an OS:apns/fcm/web. The OS label is a frontend concern carried in the channelname, never inferred fromplatform.- Payloads carry no message content: only
{type, thread_id, message_id, mailbox_id, unread_count}(routing ids + badge). The device wakes and refetches over its authenticated session — the push never carries subject/body/sender. Note only Web Push is end-to-end encrypted (RFC 8291); for APNs/FCM those routing ids and the count are visible to Apple/Google in transit, the content never is. - Push is the app-closed half of notifications; the realtime SSE relay is the app-open half. They must cooperate (see §6).
2. The core idea: one frontend, three transports
The same React app runs in three runtimes; each yields a different transport that maps 1:1 onto a backend sender:
| Runtime | Token source | platform |
Backend sender |
|---|---|---|---|
| Capacitor iOS (native) | @capacitor/push-notifications → APNs device token |
apns |
send_apns |
| Capacitor Android (native) | @capacitor/push-notifications → FCM registration token |
fcm |
send_fcm |
| Browser / installed PWA | Web Push API (PushManager.subscribe) |
web |
send_webpush |
Pick the path at runtime:
import { Capacitor } from "@capacitor/core";
const transport = Capacitor.isNativePlatform()
? (Capacitor.getPlatform() === "ios" ? "apns" : "fcm")
: "web";
Notes:
@capacitor/push-notificationsreturns the APNs token on iOS and the FCM token on Android out of the box — which is exactly our two native transports. (Don't reach for@capacitor-firebase/messagingunless you want FCM on iOS too; we don't.)- iOS WKWebView does not support the Web Push API. Inside the native iOS
shell you must use the native plugin (
apns);webis only for real browsers / installed PWAs. Android WebView likewise: use the native plugin.
3. Configuration & client requirements
/config exposes push capability + the VAPID public key
/config exposes:
PUSH_ENABLED(bool) — so the UI shows/hides notification controls instead of probing the registration endpoint and reacting to a 404.PUSH_VAPID_PUBLIC_KEY(base64url string) — required for the browser to callPushManager.subscribe({ applicationServerKey }). It's public by definition. Derive it once fromPUSH_VAPID_PRIVATE_KEYwith thederive_vapid_public_keymanagement command and pin the printed value in this env var —/configserves it verbatim and never derives it on the request path (that would force the web worker to import the push/crypto graph). Without it, web push cannot work. See §12 for the full operator checklist.
Native (apns/fcm) needs neither — the OS plugins carry their own creds
(APNs entitlement, bundled google-services.json).
Client-side localization strings (native client)
The senders emit a visible, high-priority, content-free alert (it survives force-quit, where a silent background push would be throttled and dropped). Because the push carries only localization keys — never message content — the native apps ship the matching strings so the OS can render the banner:
- iOS: the alert carries
alert.loc-key = "NEW_MESSAGE"+ the unread badge; the matchingLocalizable.stringsentries ship inios/App/App/{en,fr}.lproj/. - Android: the FCM message carries an OS-localized
notificationblock (title_loc_key/body_loc_key); the matchingnew_message_*entries ship inandroid/app/src/main/res/values{,-fr}/strings.xml. The OS displays it automatically even when the app is killed. It renders on thenew_messagesnotification channel — created at HIGH importance by the app (ensureAndroidNotificationChannel, features/native/push.ts), targeted bychannel_idin the message (FCM_ANDROID_CHANNEL_ID, fcm.py) and declared as manifest default, with the monochromeic_stat_notificationstatus icon. Without it Android 8+ would fall back to the SDK's "Miscellaneous" channel at DEFAULT importance (no heads-up).
No server toggle is involved — visible alerts are the built-in behavior; if the loc-key strings are missing the banner renders blank. (The Web Push client needs no such strings: the service worker renders the banner text itself.)
Native app project config (native client)
- iOS — in the repo: the Push Notifications capability
(
ios/App/App/App.entitlements,aps-environment = development— Xcode's distribution export rewrites it toproduction) and Background Modes (remote notifications,Info.plist). Operator-supplied: an APNs auth key (.p8) →PUSH_APNS_KEY/_KEY_ID/_TEAM_ID, bundle id →PUSH_APNS_BUNDLE_ID(the App ID must have the Push capability enabled in the Apple developer portal).PUSH_APNS_USE_SANDBOXmust match the build's signing:Truefor development-signed builds (aps-environment = development),Falsefor distribution. - Android — in the repo: the
POST_NOTIFICATIONSpermission and a conditionalcom.google.gms.google-servicesapply (skipped with a log when the file is absent, so push-less builds still work). Operator-supplied: the Firebase project'sgoogle-services.jsondropped intoandroid/app/(gitignored, per-instance likeMOBILE_APP_ID) — it must contain a client whose package name equals the build'sapplicationId(MOBILE_APP_ID), or the build fails at the google-services step; service-account JSON →PUSH_FCM_CREDENTIALS, project id →PUSH_FCM_PROJECT_ID. (No sandbox switch — staging is a separate Firebase project.)
Developer-facing walkthrough (dev Firebase project, sandbox pairing, smoke test): mobile.md, Push notifications in dev.
Auth inside the WebView (native client — not push-specific, but a dependency)
The app authenticates via OIDC and calls the API with cookies + CSRF. In a
Capacitor shell the app origin is capacitor://localhost / https://localhost
while the API is remote, so session cookies are cross-site: they need
SameSite=None; Secure, and the OAuth round-trip must go through the system
browser (ASWebAuthenticationSession / Chrome Custom Tabs) with a deep-link
redirect back. This is a prerequisite for the native client, independent of push.
4. Registration lifecycle (client)
- Ask for permission contextually — not on first launch. Tie it to a value moment or an explicit "Enable notifications" toggle (§5). Browsers penalize prompt-on-load; iOS only lets you ask once.
- Get the token:
- native:
PushNotifications.register()→registrationevent → token. - web: register the service worker, then
reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })where the key isPUSH_VAPID_PUBLIC_KEYfrom/config. Send the subscriptionendpointastokenand{p256dh, auth}askeys.
- native:
- Register:
POST /users/me/channels/with{type: "push", platform, token, keys?, name, app_version}. Use a human device name (@capacitor/device→ model). Store the registered token locally — device sign-out (§7) recognises this device's server row by comparingsha256("push:" + token)to the row'stoken_hash. Nothing needs storing for logout: revocation there is server-side (step 5). - Re-register whenever the token rotates (
registrationagain / webpushsubscriptionchange), and on launch as a cheap idempotent refresh. Note this is a client convention, not an OS guarantee — the platforms don't force a per-launch round-trip; re-registering is just the most reliable way to catch a silently-rotated token (strongest on iOS, optional on Android/Web). - On logout: handled server-side. Each registration stamps the channel
with a hash of the registering Django session; the
user_logged_outreceiver (core/signals.py) deletes the channels bound to the session being destroyed. So a voluntary logout silences exactly that device — even if no client code runs — while a session that merely expires never reaches the logout view authenticated, so its channels survive and notifications keep flowing (content-free banner until the next login re-authenticates enrichment). Clients therefore do NOT need toDELETEorunsubscribe()at logout; the re-registration on next login (step 4) transparently resumes delivery. The full decision tree (incl. shared-computer takeover) is diagrammed in §12 → Web Push specifics → Web session lifecycle — flow.
5. Receiving, displaying, deep-linking
- Tap → deep link: the payload carries
thread_id/message_id; route to the thread on notification open. Register the tap handler (pushNotificationActionPerformed/ SWnotificationclick). - Badge: from
unread_count(set asaps.badgeon the iOS alert path; app sets it on Android/Web where supported). - Richer content without weakening privacy (fetch-to-enrich): the thin push +
mutable-content: 1supports a fetch-to-enrich flow — the client fetches the message over the authenticated session and rewrites the banner with sender/subject, keeping content off the push transport. The Web Push service worker does this today (it fetches/api/v1.0/messages/{id}and falls back to a generic banner if the fetch fails). The native equivalent — an iOS Notification Service Extension / Android data handler — is the same pattern for the native client.
6. Cooperation with the realtime SSE relay
When the app is open and in front of the user, it surfaces the mail on its own (today the 30s mailbox poll; the SSE relay when it lands) — a banner for it is noise. So no transport alerts while the app holds the foreground; only the badge tracks.
- Native (iOS): the foreground-presentation handler is limited to the badge
(
presentationOptions: ["badge"]incapacitor.config.ts) — no banner, no sound. - Native (Android): nothing to configure. FCM only auto-displays the
notificationblock when the app is backgrounded; in foreground it routes to the app instead, which does not display it. - Web: the service worker skips
showNotification(and the app badge) whenclients.matchAll()reports a focused window displaying the mailbox the message landed in, read from the window's URL (/mailbox/{mailboxId}). A focused window on another mailbox or page still gets the banner: the 30s poll only refreshes the mailbox being viewed, so nothing else would surface the message. This is the one caseuserVisibleOnly: truetolerates a silent push — browsers only substitute their own "site updated in the background" notice when no window of the origin is visible. - Coordinate de-dup on
message_idso a message that arrives via both SSE and push updates the UI once.
7. The Integrations / Notifications UI
Devices do not belong in the existing Integrations view. That view is
per-mailbox (/mailboxes/{id}/channels/) for widget/webhook/api_key/CalDAV —
"connect an external system to this mailbox." Push devices are per-user
(/users/me/channels/), auto-registered by the OS permission flow, and are
"the phone/browser I read mail on." Different scope, different lifecycle,
different mental model. See §8 for the full rationale.
New account-level "Notifications" section
- Capability-gated on
config.PUSH_ENABLED(hide entirely otherwise). - This device: a primary toggle "Enable notifications on this device" that
drives the OS permission prompt + device-registration
POST/DELETE. Handle the three states: granted, denied (link out to OS settings — you can't re-prompt), unsupported (e.g. web push in an iOS WKWebView). - Your devices: list every registered device —
name, a platform icon (derived fromplatform, or a stored OS hint), and "Added on" (the date). Note:last_used_atis set at registration, not on each push, so label it "Added", not "Last used" (or updatelast_used_aton send if you want a true value). Highlight the current device. Per-row "Sign out this device" →DELETE. - Empty state: "No devices yet — enable notifications above."
- Future: granularity (mentions only / per-mailbox / quiet hours), per-device rename.
The existing (mailbox) Integrations view
No change needed — it lists mailbox-scoped channels and never queries
/users/me/channels/, so push devices can't leak in. If a unified "Integrations"
landing is ever built from /users/me/channels/, filter out type=push
there and surface devices only under Notifications.
8. Should "mobile apps" be a user-facing integration type? — No
- Scope mismatch. Integrations are mailbox-scoped; devices are user-scoped. They live on different endpoints and don't share a list.
- Mental model. Nobody thinks of their own phone as an "integration." Apple and Google call this "Notifications" / "Devices."
- Lifecycle. Integrations are deliberately created (mint a key, paste a URL). Devices are auto-registered by the OS permission flow — there's no "Add" form to show.
- Actions. Integrations: create / rotate / delete. Devices: toggle, rename, sign out.
Keep Channel(type="push") as the storage mechanism (it gives us device
management for free), but present it as Notifications settings, not an
integration. The transport (apns/fcm/web) stays an implementation detail;
the user sees "iPhone / Android phone / this browser."
9. Security model — push-token reclaim
Device registration reclaims a token from another user on account switch
(push.py, register_push_device). Channel.lookup_hash (sha256 of the
push:-prefixed token) is globally unique. A re-registration by the same user updates that user's row
in place; a registration of a token currently owned by a different user
deletes that user's row and creates a fresh channel for the caller — it
does not reassign the existing row, so the new owner never inherits the previous
owner's channel id, created_at or device label. This is a privacy guard, not
a convenience — if user A logs out and user B logs in on the same physical device,
the OS may hand the app the same push token; without reclaim, A's private
notifications would keep flowing to a device now showing B's account.
Known, accepted risk. Reclaim is authorized purely by presenting a raw token
that hashes to the victim's stored lookup_hash — there's no proof the registrant
controls the device. So an authenticated user who obtains another user's raw
push token can take over that user's device channel.
Why we accept it (rather than the recency-at-send-time or proof-of-control alternatives):
- The stored hash is not the key.
lookup_hashissha256("push:" + token)and is preimage-resistant, so a DB/column leak does not enable the attack. The attacker needs the raw token, which lives only encrypted inencrypted_settings.tokenand on the device itself. - Impact is a self-healing notification DoS. The victim silently stops receiving pushes until their real device re-registers (every launch — step §4.4 re-registers idempotently), which recreates their channel and wins back the token.
- No content disclosure. The attacker does not hold the device, so they receive nothing; payloads are content-free regardless (§1).
- Raw push tokens are device-held routing identifiers, not credentials issued to other users — obtaining a victim's raw token already implies a meaningful compromise (device access, malicious SDK, client log exfiltration).
If the threat model tightens (tokens treated as semi-public, or DoS on
notifications becomes unacceptable), the fix is to stop deleting another user's
row at registration and instead resolve token conflicts at send time by
recency (push only to the most recently registered channel sharing a
lookup_hash) — same privacy guarantee, non-destructive, trivially reversible.
10. Extending: differentiated priority / importance by label
Today every push is sent visible + high-priority (the senders hardcode it — there is deliberately no deployment-wide priority/alert toggle; an instance-wide flag is one value for the whole server, so it could never express "label X matters more than label Y" anyway). When we want per-message importance — e.g. high-priority for a VIP/important label, normal for the rest — the seam is already there and the change is small.
Why it's easy: priority has the same granularity as collapse_key.
send_push_for_message (push.py) runs once per message, and it already
derives a per-message collapse_key next to where the message (hence its
labels/thread) is in scope, then threads it into every sender as an argument.
Per-message priority is the same shape, so it follows the same three steps:
- Derive it where
collapse_keyis derived — apriority_for_message(message)helper that inspects the message's labels/importance. This is the content/importance decision, and it belongs here (upstream, near the compose/fan-out), not in the transport sender. - Thread it as a second context arg alongside
collapse_key:sender(items, collapse_key, priority). - Map it per transport inside each sender:
- FCM (
android.priority) and Web Push (urgencyheader) already build their priority value inside the per-item loop — read the arg instead of the"high"literal. - APNs (
apns-priorityheader) is computed once before the loop, exactly likeapns-collapse-idis fromcollapse_key— compute it from the arg.
- FCM (
Since a sender call is one message's fan-out, all its items share the message's
priority — so this is a per-call parameter, not per-item; no per-device state.
Graded intrusiveness, not a binary. If "low importance" should mean less
intrusive rather than just normal urgency, prefer the modern per-notification
primitives over reviving the old visible/silent binary (which had iOS
throttling/force-quit problems): iOS interruption-level / relevance-score,
Android notification-channel importance, Web urgency. All are per-notification
fields that thread through the same per-message seam.
This is really one facet of the user-facing importance/content-filter feature (§ "which messages notify"): the label decides importance; importance maps to transport priority here.
11. Not yet built / future work
- Native fetch-to-enrich. The Web Push service worker already enriches banners (§5); the iOS Notification Service Extension / Android data handler equivalent is not built — native banners stay at the generic loc-key text.
- Differentiated priority / importance by label (§10) — the seam exists; wiring it to a label/importance signal is future work.
- Per-device granularity (mentions only / per-mailbox / quiet hours) and per-device rename, beyond the current enable/list/sign-out (§7).
12. Delivery & operations (as implemented)
Task model — one Celery task per notification
enqueue_push_notifications (on commit) schedules send_push_for_message, the
orchestrator: it resolves recipients and does the per-recipient DB work once
(devices, badge counts, deep-link mailbox — a handful of batched queries),
builds each user's thin payload, then dispatches one send_push_notification
task per device. The orchestrator never touches a gateway, so a flaky provider
can't stall resolution.
Each send_push_notification is the independently-retryable atomic unit: it
re-fetches its one channel (skips if the device was un-associated since
dispatch), sends one push, and on a transient failure (429 / 5xx / network)
retries just that notification (autoretry_for=PushTransientError,
exponential backoff, max_retries=5). Retries are idempotent on-device — the
per-thread collapse key / Web Push Topic coalesces a re-send onto the same
notification. acks_late=True means a worker crash re-runs that one push, not
the whole fan-out. Dead-token devices are deleted; permanent rejections end the
task.
Why per-notification, not batched: the gateways have no multi-device batch API — APNs is one HTTP/2 request per token, FCM v1 is one request per token (legacy multicast is removed), Web Push is one request per subscription. So parallelism, not batching, is the lever, and the Celery worker pool provides it.
Scale
Mailboxes are bounded (~50–100 members; not distribution lists), so the common case is "internal email to ~100 recipients × ~2 devices" ≈ 200 pushes/message (upper bound ~500). That's well within the per-notification-task model.
Gateway efficiency
- Cached auth tokens (shared via the redis cache, refreshed inside their
validity): the APNs ES256 provider token (Apple throttles re-minting —
TooManyProviderTokenUpdates) and the FCM OAuth access token. So the many per-notification tasks don't each re-authenticate. - Process-global HTTP clients for APNs (HTTP/2, multiplexed) and FCM (HTTP/1.1, keep-alive), reused across a worker's tasks and closed on worker shutdown — avoids a TLS handshake per push and Apple's rapid-connect/disconnect abuse heuristic. Web Push can't share a client (per-subscription host, delivered through a per-request SSRF-IP-pinned session).
Stale-device deletion (two guards)
Only unambiguous dead-token signals delete a channel: APNs 410 Unregistered
(NOT BadDeviceToken — that's usually a wrong-env PUSH_APNS_USE_SANDBOX),
FCM UNREGISTERED / NOT_FOUND (NOT INVALID_ARGUMENT — also a bad-request
signal), Web Push 404 / 410. On top of the narrow codes:
- a per-batch ratio breaker (refuse if ≥50% of a ≥4-device batch is "stale"), and
- a rolling per-platform window (cap of 500 deletions/60s) that covers the per-notification path, where there's no batch to ratio-check — so a systemic fault can't wipe a fleet one task at a time.
Genuinely-bad tokens that never 410 are still reclaimed by re-registration (same-user upsert replaces the row), the per-user device cap (LRU eviction), or manual removal in device management.
Web Push specifics
- VAPID keys must stay paired. The browser subscribes with
PUSH_VAPID_PUBLIC_KEYas itsapplicationServerKey; the push service then verifies every notification against the JWT the private key signs. A mismatch fails all web push silently (403). The public key is deterministic from the private key — derive it withpython manage.py derive_vapid_public_keyand pin it;--verifychecks the configured pair./configserves the env var verbatim and never derives it (keeps the push/crypto graph off the request path). Rotating the private key orphans every existing web subscription. PUSH_VAPID_SUBJECTmust be amailto:/https:URI (else 401); a malformed subject disables web push with a logged warning.- TTL is 1 day (the payload is just a refetch trigger; a week-old trigger is
noise),
Urgency: high,Topic= a 32-char hash of the collapse key. - The service worker re-alerts per new message in a thread (
renotify). - Self-healing subscriptions: the SW's
pushsubscriptionchangere-subscribes and re-registers (CSRF viacookieStoreon Chromium); the app also re-registers the existing subscription on load (CSRF-correct path) so a rotated endpoint doesn't silently go dark without the user revisiting settings. - VAPID key rotation is detected client-side:
enableWebPush/refreshWebPushSubscriptioncompare the existing subscription'sapplicationServerKeyto the currentPUSH_VAPID_PUBLIC_KEYand, on a mismatch,unsubscribe()+ re-subscribe()with the new key (the push service otherwise rejects the stale subscription with 401/403 forever, which the backend can't prune — it only prunes on 404/410). The current key is injected into the SW script URL (?vapid=) sopushsubscriptionchangere-subscribes with it rather than the old key. - Voluntary logout vs session expiry: the product rule is "a device stops
receiving on explicit logout, keeps receiving across session expiry, and
resumes transparently on the next login". It is enforced server-side: the
channel is stamped with
sha256("sess:" + session_key)at registration (settings.session_hash), and theuser_logged_outreceiver deletes the channels bound to the logging-out session. The 401/expiry funnel reaches the logout view anonymous (the session is already gone), so it matches nothing — the distinction needs no client code and works even when the browser is closed. The browser subscription itself is never destroyed at logout. - Per-user opt-in marker (
localStorage): because the browser push subscription andlocalStorageare per-origin, not per-user, a live subscription is NOT proof the current user opted in (it may be a previous user's leftover on a shared computer). Enabling push storesmessages_push-opt-in.<userId>; the on-load refresh (re)registers only when that marker is present for the current user — or, for users who opted in before the marker existed, when the server device list proves they own the endpoint (token_hashmatch, which also migrates them onto the marker). This is what recreates the channel after a voluntary logout for the returning user only, and never enrolls a different user on the same machine. - Different user takes over the browser (expired session): an expired
session deletes nothing server-side, so on a shared computer the previous
user's channel would keep alerting. The refresh closes this: when the
authenticated user is not entitled to the live subscription (no marker, no
token_hashownership), itunsubscribe()s it — a browser-local call that needs no rights on the other user's channel. The endpoint dies at the push service (nothing is delivered anymore) and the orphaned channel self-prunes on its next send (404/410 → stale). The previous user's opt-in marker survives, so their own next login here re-subscribes them fresh. - Sign-out of a device (settings): the durable per-device opt-out. When the
signed-out row is the current browser (matched via
token_hash, the server-exposedsha256("push:" + endpoint)), the clientunsubscribe()s locally and clears the user's opt-in marker before theDELETE, so the on-load refresh can't recreate it.
Web session lifecycle — flow
The scenario matrix the bullets above implement:
| Scenario | Behaviour | Mechanism |
|---|---|---|
| A logs out voluntarily | A's notifications stop instantly, this device only | server: user_logged_out → delete session-stamped channel |
| A's session expires (A alone on the machine) | notifications continue (content-free banner) | 401 funnel reaches logout anonymous → receiver no-ops |
| A logs back in (after logout or expiry) | resumes automatically | opt-in marker → on-load re-registration |
| A's session expires, B logs in | A's notifications stop at B's first load; B is not enrolled | refresh: B not entitled → unsubscribe() → channel self-prunes (404/410) |
| A returns after B | resumes automatically for A | A's marker survived → fresh subscription |
| B explicitly enables push | clean takeover | cross-user reclaim in register_push_device |
What happens when a session ends, and how delivery resumes:
flowchart TD
START(["User A's session ends"]) --> HOW{How?}
HOW -- "voluntary logout<br/>(UI button → /logout/)" --> SIG["user_logged_out receiver:<br/>delete A's channels stamped<br/>with THIS session"]
SIG --> QUIET["Device stops receiving instantly.<br/>Browser subscription and A's<br/>opt-in marker survive"]
HOW -- "expiry / 401 funnel" --> NOOP["Logout view reached anonymous:<br/>receiver matches nothing.<br/>Channel survives — pushes continue<br/>(content-free banner)"]
QUIET --> NEXT{"Next login<br/>in this browser"}
NOOP --> NEXT
NEXT -- "same user A" --> RESUME["On-load refresh re-registers<br/>(marker / token_hash ownership).<br/>Notifications resume, no action"]
NEXT -- "different user B" --> TAKE["B is not entitled — no marker,<br/>no ownership → unsubscribe():<br/>endpoint dies at the push service"]
TAKE --> PRUNE["A's orphaned channel self-prunes<br/>on its next send (404/410 → stale).<br/>B is NOT enrolled"]
PRUNE --> BACK["A's next login here:<br/>marker survived → fresh<br/>subscription, resumes"]
The on-load refreshWebPushSubscription decision tree that enforces the
client-side half (never prompts, never enrolls a non-entitled user):
flowchart TD
LOAD(["App load, authenticated user"]) --> SUP{"Web Push supported and<br/>permission granted?"}
SUP -- no --> S1(["stop — never prompt"])
SUP -- yes --> REG{"SW registration<br/>exists?"}
REG -- no --> S2(["stop — push never<br/>enabled in this browser"])
REG -- yes --> RR["re-register SW<br/>(refresh ?api= and ?vapid=)"]
RR --> SUB{"Live<br/>subscription?"}
SUB -- no --> MK1{"Opt-in marker for<br/>current user?"}
MK1 -- no --> S3(["stop — stay passive"])
MK1 -- yes --> NEW["subscribe() with the<br/>current VAPID key"]
SUB -- yes --> MK2{"Opt-in marker for<br/>current user?"}
MK2 -- yes --> ROT
MK2 -- no --> OWN{"Server owns this endpoint?<br/>(device list token_hash match)"}
OWN -- yes --> ROT{"Subscription key matches<br/>current VAPID key?"}
OWN -- no --> TEAR["unsubscribe() — previous user's<br/>leftover; orphaned channel<br/>self-prunes on next send"]
ROT -- no --> RESUB["unsubscribe() + subscribe()<br/>with the current key<br/>(VAPID rotation self-heal)"]
ROT -- yes --> POST
RESUB --> POST
NEW --> POST["POST /users/me/channels/ type=push<br/>(server stamps session_hash)"]
POST --> MARK(["set opt-in marker<br/>for current user"])
Operator setup checklist (Web Push)
python manage.py generate_vapid_private_key→ prints a fresh keypair; setPUSH_VAPID_PRIVATE_KEY(base64url single line) and its matchingPUSH_VAPID_PUBLIC_KEYfrom the printed guidance, plusPUSH_VAPID_SUBJECT(mailto:…).PUSH_VAPID_PRIVATE_KEYalso accepts a PEM block if you already have one (e.g. fromweb-push/vapid).- Already have only the private key?
python manage.py derive_vapid_public_key→ set the output asPUSH_VAPID_PUBLIC_KEY. python manage.py derive_vapid_public_key --verifyto confirm the pair.PUSH_ENABLED=true. (APNs/FCM need their own credential env vars; each transport no-ops until its credentials are present.)