Files
lasuite-messages/docs/push-notifications.md
T

32 KiB
Raw Blame History

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 in encrypted_settings.token (+ keys for Web Push); settings holds platform and app_version; the dedup/reclaim key is the indexed, globally-unique Channel.lookup_hash column (sha256 of the push:-prefixed token).
  • Register / refresh: POST /api/v1.0/users/me/channels/ with {type: "push", platform, token, keys?, name?, app_version?}. The collection POST routes type=push to an idempotent upsert (201 first time, 200 on refresh); reclaims a token from another user on account switch. 404 when PUSH_ENABLED is 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.
  • platform is a transport, not an OS: apns / fcm / web. The OS label is a frontend concern carried in the channel name, never inferred from platform.
  • 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-notificationsAPNs device token apns send_apns
Capacitor Android (native) @capacitor/push-notificationsFCM 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-notifications returns 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/messaging unless 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); web is 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 call PushManager.subscribe({ applicationServerKey }). It's public by definition. Derive it once from PUSH_VAPID_PRIVATE_KEY with the derive_vapid_public_key management command and pin the printed value in this env var — /config serves 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 matching Localizable.strings entries ship in ios/App/App/{en,fr}.lproj/.
  • Android: the FCM message carries an OS-localized notification block (title_loc_key / body_loc_key); the matching new_message_* entries ship in android/app/src/main/res/values{,-fr}/strings.xml. The OS displays it automatically even when the app is killed. It renders on the new_messages notification channel — created at HIGH importance by the app (ensureAndroidNotificationChannel, features/native/push.ts), targeted by channel_id in the message (FCM_ANDROID_CHANNEL_ID, fcm.py) and declared as manifest default, with the monochrome ic_stat_notification status 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 to production) 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_SANDBOX must match the build's signing: True for development-signed builds (aps-environment = development), False for distribution.
  • Android — in the repo: the POST_NOTIFICATIONS permission and a conditional com.google.gms.google-services apply (skipped with a log when the file is absent, so push-less builds still work). Operator-supplied: the Firebase project's google-services.json dropped into android/app/ (gitignored, per-instance like MOBILE_APP_ID) — it must contain a client whose package name equals the build's applicationId (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)

  1. 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.
  2. Get the token:
    • native: PushNotifications.register()registration event → token.
    • web: register the service worker, then reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }) where the key is PUSH_VAPID_PUBLIC_KEY from /config. Send the subscription endpoint as token and {p256dh, auth} as keys.
  3. 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 comparing sha256("push:" + token) to the row's token_hash. Nothing needs storing for logout: revocation there is server-side (step 5).
  4. Re-register whenever the token rotates (registration again / web pushsubscriptionchange), 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).
  5. On logout: handled server-side. Each registration stamps the channel with a hash of the registering Django session; the user_logged_out receiver (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 to DELETE or unsubscribe() 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 specificsWeb 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 / SW notificationclick).
  • Badge: from unread_count (set as aps.badge on 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: 1 supports 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"] in capacitor.config.ts) — no banner, no sound.
  • Native (Android): nothing to configure. FCM only auto-displays the notification block 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) when clients.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 case userVisibleOnly: true tolerates 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_id so 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 from platform, or a stored OS hint), and "Added on" (the date). Note: last_used_at is set at registration, not on each push, so label it "Added", not "Last used" (or update last_used_at on 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_hash is sha256("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 in encrypted_settings.token and 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:

  1. Derive it where collapse_key is derived — a priority_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.
  2. Thread it as a second context arg alongside collapse_key: sender(items, collapse_key, priority).
  3. Map it per transport inside each sender:
    • FCM (android.priority) and Web Push (urgency header) already build their priority value inside the per-item loop — read the arg instead of the "high" literal.
    • APNs (apns-priority header) is computed once before the loop, exactly like apns-collapse-id is from collapse_key — compute it from the arg.

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 (~50100 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_KEY as its applicationServerKey; 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 with python manage.py derive_vapid_public_key and pin it; --verify checks the configured pair. /config serves 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_SUBJECT must be a mailto: / 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 pushsubscriptionchange re-subscribes and re-registers (CSRF via cookieStore on 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 / refreshWebPushSubscription compare the existing subscription's applicationServerKey to the current PUSH_VAPID_PUBLIC_KEY and, 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=) so pushsubscriptionchange re-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 the user_logged_out receiver 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 and localStorage are 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 stores messages_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_hash match, 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_hash ownership), it unsubscribe()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-exposed sha256("push:" + endpoint)), the client unsubscribe()s locally and clears the user's opt-in marker before the DELETE, 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)

  1. python manage.py generate_vapid_private_key → prints a fresh keypair; set PUSH_VAPID_PRIVATE_KEY (base64url single line) and its matching PUSH_VAPID_PUBLIC_KEY from the printed guidance, plus PUSH_VAPID_SUBJECT (mailto:…). PUSH_VAPID_PRIVATE_KEY also accepts a PEM block if you already have one (e.g. from web-push/vapid).
  2. Already have only the private key? python manage.py derive_vapid_public_key → set the output as PUSH_VAPID_PUBLIC_KEY.
  3. python manage.py derive_vapid_public_key --verify to confirm the pair.
  4. PUSH_ENABLED=true. (APNs/FCM need their own credential env vars; each transport no-ops until its credentials are present.)