mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
✨(push) add Push Notifications system for iOS, Android, Web
This commit is contained in:
+6
-2
@@ -385,10 +385,14 @@ services:
|
||||
environment:
|
||||
HOME: /tmp
|
||||
env_file:
|
||||
- env.d/development/frontend.defaults
|
||||
- env.d/development/frontend.local
|
||||
- deploy/env/frontend.defaults
|
||||
- deploy/env/frontend.local
|
||||
volumes:
|
||||
- ./src/frontend/:/home/frontend/
|
||||
# Share the same fast node_modules volume as frontend-dev so that
|
||||
# `make install-frozen-front` (which runs here) installs into the volume
|
||||
# that frontend-dev consumes.
|
||||
- frontend-node-modules:/home/frontend/node_modules
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:4.11.0@sha256:9b32eafcfd8ba4b5183bd601bfeb8a65b0d338f2d2fc36df155f4845ee244eff
|
||||
|
||||
Vendored
+11
@@ -83,6 +83,17 @@ MOBILE_AUTH_CALLBACK_SCHEMES=["stmessages"]
|
||||
# frontend.defaults to exercise the OTA chain.
|
||||
# MOBILE_OTA_MANIFEST_URL=http://localhost:8906/messages-ota/channels/dev/manifest.json
|
||||
|
||||
# Push notifications — OPT-IN in dev: dark by default, per-developer
|
||||
# credentials go in backend.local (see docs/mobile.md, "Push notifications in
|
||||
# dev", and docs/env.md for the full reference). Each gateway no-ops until its
|
||||
# variables are all set.
|
||||
# - Web Push: `python manage.py generate_vapid_private_key` prints the three
|
||||
# PUSH_VAPID_* values to set.
|
||||
# - iOS: PUSH_APNS_KEY/_KEY_ID/_TEAM_ID/_BUNDLE_ID + PUSH_APNS_USE_SANDBOX=True
|
||||
# (dev-signed builds hold sandbox tokens).
|
||||
# - Android: PUSH_FCM_CREDENTIALS/_PROJECT_ID from your dev Firebase project.
|
||||
# PUSH_ENABLED=True
|
||||
|
||||
# keycloak
|
||||
IDENTITY_PROVIDER=keycloak
|
||||
KEYCLOAK_REALM=messages
|
||||
|
||||
Vendored
-5
@@ -15,11 +15,6 @@ NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE=100
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT=
|
||||
|
||||
## Theme Customization
|
||||
NEXT_PUBLIC_THEME_CONFIG='{
|
||||
"theme": "white-label"
|
||||
}'
|
||||
|
||||
## Mobile app build identity (Capacitor). Neutral placeholder shared by the repo;
|
||||
## read by `cap sync` (container) and by the native builds (host: gradle / Xcode).
|
||||
## An organisation publishing to the stores overrides it — in BOTH contexts — with
|
||||
|
||||
+23
-1
@@ -191,7 +191,7 @@ variables (read by `src/frontend/scripts/*-ota*.mjs`, not by Django). They live
|
||||
in the frontend env files; in CI they come from secrets.
|
||||
|
||||
> **Opt-in in dev**: they ship **commented out** in
|
||||
> `env.d/development/frontend.defaults` (the values below are the working
|
||||
> `deploy/env/frontend.defaults` (the values below are the working
|
||||
> dev-stack ones) — uncomment them, along with `MOBILE_OTA_MANIFEST_URL` in
|
||||
> `backend.local`, to exercise the OTA chain locally.
|
||||
> Hot reload (`MOBILE_DEV_SERVER_URL`, on by default in dev) skips the startup
|
||||
@@ -450,6 +450,27 @@ without redeploying the frontend (the flag is pulled from
|
||||
| `FEATURE_AI_SUMMARY` | `False` | Default enabled mode for summary AI features | Required |
|
||||
| `FEATURE_AI_AUTOLABELS` | `False` | Default enabled mode for label AI features | Required |
|
||||
|
||||
### Push Notifications
|
||||
|
||||
Push notifications for new messages, delivered to iOS (APNs), Android (FCM) and web browsers (Web Push / VAPID). The feature is **off by default** (`PUSH_ENABLED=False`): no tokens are pushed to and no external gateway is contacted. To go live, set `PUSH_ENABLED=True` **and** fully configure at least one gateway below.
|
||||
|
||||
Each gateway is all-or-nothing, and validated at boot: with `PUSH_ENABLED=True`, each gateway's variables must be set **together or not at all** (e.g. `PUSH_VAPID_PRIVATE_KEY`, `PUSH_VAPID_PUBLIC_KEY` and `PUSH_VAPID_SUBJECT` for Web Push), otherwise Django raises a `ValueError` at startup — a partial group would enroll devices that never receive anything. Device registration additionally refuses (400) any platform whose gateway is absent altogether, and a sender that finds itself without credentials at send time (removed *after* devices enrolled) drops with a deduplicated warning rather than silently. A deployment configuring only some gateways (e.g. native-only, no VAPID vars) boots and runs fine.
|
||||
|
||||
| Variable | Default | Description | Required |
|
||||
|----------|---------|-------------|----------|
|
||||
| `PUSH_ENABLED` | `False` | Master switch. When `False`, the feature is fully dark: no gateway is contacted and the enqueue helper never schedules the Celery task. | Optional |
|
||||
| `PUSH_APNS_KEY` | None | Contents of the APNs auth key `.p8` file (PEM). Required for iOS (with the three vars below). | Optional |
|
||||
| `PUSH_APNS_KEY_ID` | None | APNs auth key id (Key ID from the Apple developer portal). | Optional |
|
||||
| `PUSH_APNS_TEAM_ID` | None | Apple developer Team ID. | Optional |
|
||||
| `PUSH_APNS_BUNDLE_ID` | None | App bundle id, used as the APNs topic. | Optional |
|
||||
| `PUSH_APNS_USE_SANDBOX` | `False` | `False` targets Apple's production gateway; `True` the sandbox gateway (only accepts tokens from a development-signed build). | Optional |
|
||||
| `PUSH_FCM_CREDENTIALS` | None | Firebase service-account JSON (the whole file contents) as a string. Required for Android (with `PUSH_FCM_PROJECT_ID`). | Optional |
|
||||
| `PUSH_FCM_PROJECT_ID` | None | Firebase project id; selects the FCM HTTP v1 endpoint. Separate staging from production by pointing at a different Firebase project, not a flag. | Optional |
|
||||
| `PUSH_VAPID_PRIVATE_KEY` | None | VAPID application-server private key (PEM or base64url). Required for Web Push (see boot coupling above). Rotating it orphans every existing web subscription — clients must re-subscribe — and requires re-deriving the public key. | Optional |
|
||||
| `PUSH_VAPID_PUBLIC_KEY` | None | Matching VAPID public key (base64url, the uncompressed P-256 point). Served verbatim via `/config` as the browser's `applicationServerKey`; public by definition. Derive it from the private key with `python manage.py derive_vapid_public_key` (add `--verify` to check the pair matches). | Optional |
|
||||
| `PUSH_VAPID_SUBJECT` | None | VAPID `sub` claim; must be a `mailto:` or `https:` URI, e.g. `mailto:ops@example.com`. | Optional |
|
||||
| `PUSH_MAX_DEVICES_PER_USER` | `20` | Hard ceiling on push devices one user may keep. Registering beyond it prunes the least-recently-used device(s). | Optional |
|
||||
|
||||
### Throttling
|
||||
|
||||
Outbound message throttling limits the number of **external recipients** (recipients whose domain is not managed by this instance) that can be sent from a mailbox or maildomain within a time period, using simple fixed time windows.
|
||||
@@ -465,6 +486,7 @@ Outbound message throttling limits the number of **external recipients** (recipi
|
||||
| `API_CALDAV_CONFLICTS_THROTTLE_RATE` | `30/minute` | Rate limit on the CalDAV conflict-check API. | Optional |
|
||||
| `API_WIDGET_INBOUND_CHANNEL_THROTTLE_RATE` | `30/minute` | Rate limit on inbound widget submissions, per widget channel. | Optional |
|
||||
| `API_WIDGET_INBOUND_IP_THROTTLE_RATE` | `10/minute` | Per-IP burst limit on inbound widget submissions. | Optional |
|
||||
| `API_DEVICE_REGISTRATION_THROTTLE_RATE` | `30/hour` | Per-user rate limit on push device (re)registration. Clients re-register on every cold launch and on token rotation, so this is deliberately loose; the hard ceiling on distinct devices is `PUSH_MAX_DEVICES_PER_USER`. | Optional |
|
||||
|
||||
### Image Proxy
|
||||
|
||||
|
||||
+75
-5
@@ -229,7 +229,7 @@ The bucket hosts one **self-contained folder per channel** —
|
||||
exactly one channel: the channel segment lives in the `MOBILE_OTA_MANIFEST_URL`
|
||||
served by the backend the app talks to (`/config` endpoint). The deploy
|
||||
pipeline publishes to `staging` and `prod`; local development uses `dev`
|
||||
(commented default in `env.d/development/backend.defaults`), so experiments
|
||||
(commented default in `deploy/env/backend.defaults`), so experiments
|
||||
never look like a release.
|
||||
|
||||
**A bundle is never copied or promoted across channels.** The `NEXT_PUBLIC_*`
|
||||
@@ -341,12 +341,16 @@ revert build may still reference them.
|
||||
| Native login / logout | `src/frontend/src/features/native/auth.ts` |
|
||||
| Native CSRF token store | `src/frontend/src/features/native/csrf.ts` |
|
||||
| Native download → share | `src/frontend/src/features/native/download.ts` |
|
||||
| Native push client (enable / on-launch refresh / tap deep-link) | `src/frontend/src/features/native/push.ts` |
|
||||
| Push opt-in marker + token-hash contract (shared web/native) | `src/frontend/src/features/push/shared.ts` |
|
||||
| OTA client | `src/frontend/src/features/native/ota.ts` |
|
||||
| Startup wiring (OTA, `native` html class) | `src/frontend/src/main.tsx` |
|
||||
| CSRF / API origin wiring | `src/frontend/src/features/api/utils.ts` |
|
||||
| Login/logout routing | `src/frontend/src/features/auth/index.tsx` |
|
||||
| iOS ASWebAuthenticationSession plugin | `src/frontend/ios/App/App/WebAuthSessionPlugin.swift` |
|
||||
| iOS plugin registration | `src/frontend/ios/App/App/MainViewController.swift` |
|
||||
| iOS push entitlement + APNs bridge + banner strings | `src/frontend/ios/App/App/App.entitlements`, `AppDelegate.swift`, `{en,fr}.lproj/Localizable.strings` |
|
||||
| Android push banner strings (FCM loc-keys) | `src/frontend/android/app/src/main/res/values{,-fr}/strings.xml` |
|
||||
| SSO invariants tripwire (CI guard on the native declarations) | `src/frontend/src/features/native/sso-invariants.test.ts` |
|
||||
| Android project | `src/frontend/android/` |
|
||||
| Backend mobile-aware OIDC views | `src/backend/core/authentication/views.py` |
|
||||
@@ -412,7 +416,7 @@ end-to-end first; the list below is what this project specifically needs.
|
||||
## Build & run workflow
|
||||
|
||||
The web bundle is built **in a container** (`frontend-mobile`) so the
|
||||
`NEXT_PUBLIC_*` vars from `env.d/development/frontend.{defaults,local}` are
|
||||
`NEXT_PUBLIC_*` vars from `deploy/env/frontend.{defaults,local}` are
|
||||
inlined at build time (Vite `envPrefix: 'NEXT_PUBLIC_'`). Building on the host
|
||||
with a bare `npm run build` would inline none of them. The native compile, IDE,
|
||||
`adb` and Xcode steps run on the **host**.
|
||||
@@ -448,7 +452,7 @@ Run the `App` scheme after `make mobile-ios`.
|
||||
### Hot reload (on by default in dev)
|
||||
|
||||
`MOBILE_DEV_SERVER_URL` — set to `http://localhost:8900` (the Vite dev server)
|
||||
in `env.d/development/frontend.defaults` — is baked by `cap sync` into the app
|
||||
in `deploy/env/frontend.defaults` — is baked by `cap sync` into the app
|
||||
as Capacitor's `server.url`: the WebView loads the app straight from Vite
|
||||
instead of the embedded `dist/`, so JS/CSS changes apply through HMR without
|
||||
rebuilding or reinstalling. Since every `make mobile-*` target runs in a
|
||||
@@ -469,10 +473,10 @@ of the box**. Requirements and caveats:
|
||||
|
||||
**Disabling it** — to test the embedded bundle (what a store build ships), or
|
||||
the OTA chain end to end: set the variable **empty** in
|
||||
`env.d/development/frontend.local` (gitignored, overrides the defaults):
|
||||
`deploy/env/frontend.local` (gitignored, overrides the defaults):
|
||||
|
||||
```bash
|
||||
# env.d/development/frontend.local
|
||||
# deploy/env/frontend.local
|
||||
MOBILE_DEV_SERVER_URL=
|
||||
```
|
||||
|
||||
@@ -480,6 +484,63 @@ then rerun `make mobile-build` (or any target that wraps it) and reinstall the
|
||||
app. A leftover `server.url` fails Android **release** builds (gradle guard in
|
||||
`android/app/build.gradle`); see the release checklist for iOS.
|
||||
|
||||
## Push notifications in dev (optional)
|
||||
|
||||
Push is **off by default** (`PUSH_ENABLED=False`): the apps build, run and hide
|
||||
the notification settings without any of this. Full architecture:
|
||||
[push-notifications.md](./push-notifications.md). What ships in the repo
|
||||
(entitlements, loc-key banner strings, permission, conditional google-services
|
||||
apply) needs no setup; what follows is the per-developer credential part.
|
||||
|
||||
**The app self-configures per environment where it can** — the client picks its
|
||||
transport at runtime (`apns` on iOS / `fcm` on Android), and a dev-signed iOS
|
||||
build automatically registers against Apple's *sandbox* gateway
|
||||
(`aps-environment = development` in `App.entitlements`; Xcode's distribution
|
||||
export rewrites it to `production`). What it **cannot** infer is the backend
|
||||
half: the gateway credentials and the sandbox flag below must match the build
|
||||
you install.
|
||||
|
||||
### Android (FCM)
|
||||
|
||||
1. Create a (free) dev Firebase project and register an **Android app whose
|
||||
package name is exactly the `applicationId`** of your build — the
|
||||
`MOBILE_APP_ID` default, `local.suitenumerique.messages`. A
|
||||
`google-services.json` for another package fails the Android build at the
|
||||
google-services step.
|
||||
2. Download `google-services.json` into `src/frontend/android/app/`
|
||||
(gitignored, per-instance). Rebuild/reinstall.
|
||||
3. In Firebase console → project settings → service accounts, generate a
|
||||
service-account key and set in `deploy/env/backend.local`:
|
||||
`PUSH_ENABLED=True`, `PUSH_FCM_CREDENTIALS` (the JSON, single line),
|
||||
`PUSH_FCM_PROJECT_ID`. Restart the backend + celery worker.
|
||||
4. Emulator: use the same **Play-services image** the SSO setup already
|
||||
requires (see *Prerequisites*) — FCM registration fails on a bare AOSP
|
||||
image (the UI then shows the `registration_failed` message, by design).
|
||||
|
||||
### iOS (APNs)
|
||||
|
||||
1. **Physical iPhone required** for the end-to-end path: simulators never get a
|
||||
real APNs token, so registration against Apple's gateway can't be exercised
|
||||
there (`xcrun simctl push` only injects local payloads).
|
||||
2. Apple developer account: enable the **Push Notifications capability on the
|
||||
App ID** matching your bundle id, and create an **APNs auth key** (`.p8`).
|
||||
3. In `deploy/env/backend.local`: `PUSH_ENABLED=True`,
|
||||
`PUSH_APNS_KEY` (the `.p8` PEM), `PUSH_APNS_KEY_ID`, `PUSH_APNS_TEAM_ID`,
|
||||
`PUSH_APNS_BUNDLE_ID` (= your `MOBILE_APP_ID`), and
|
||||
**`PUSH_APNS_USE_SANDBOX=True`** — dev-signed builds hold sandbox tokens;
|
||||
against the production gateway they are rejected as `BadDeviceToken`.
|
||||
Restart the backend + celery worker.
|
||||
|
||||
### Smoke test (both platforms)
|
||||
|
||||
1. In the app: account menu → Notifications → *Enable notifications on this
|
||||
device* → accept the OS prompt. The device must appear in the list.
|
||||
2. Kill the app, send the mailbox a message from another account: a
|
||||
content-free "New message / Nouveau message" banner must show (rendered by
|
||||
the OS from the loc-key strings — a blank banner means those strings are
|
||||
missing from the build).
|
||||
3. Tap it: the app must open on the thread (deep-link path).
|
||||
|
||||
## Configuration
|
||||
|
||||
Mobile-specific environment variables (full reference in [env.md](./env.md)):
|
||||
@@ -585,6 +646,15 @@ or the Capacitor version.
|
||||
(gradle guard in `android/app/build.gradle`); Xcode has no equivalent guard,
|
||||
so **check manually for iOS** (no `server.url` in
|
||||
`ios/App/App/capacitor.config.json`).
|
||||
8. **Push environment pairing** — nothing fails loudly on a mismatch, pushes
|
||||
just never arrive (or hit `BadDeviceToken` in the sender logs). For a store
|
||||
release: the backend serving those users must run
|
||||
`PUSH_APNS_USE_SANDBOX=False` (a distribution-signed build holds
|
||||
*production* APNs tokens — Xcode rewrites `aps-environment` at export, no
|
||||
manual step); the bundled `google-services.json` must come from the
|
||||
**production** Firebase project and contain a client for the release
|
||||
`MOBILE_APP_ID`; `PUSH_APNS_BUNDLE_ID` must equal that same id. Then run the
|
||||
smoke test of *Push notifications in dev* against the release build.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
# 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-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:
|
||||
|
||||
```ts
|
||||
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](./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 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` / 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 (~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_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:
|
||||
|
||||
```mermaid
|
||||
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):
|
||||
|
||||
```mermaid
|
||||
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.)
|
||||
@@ -394,6 +394,16 @@
|
||||
"type": "string",
|
||||
"description": "OTA channel manifest URL the mobile apps poll at startup; unset disables OTA updates",
|
||||
"readOnly": true
|
||||
},
|
||||
"PUSH_ENABLED": {
|
||||
"type": "boolean",
|
||||
"description": "Whether push notifications are available on this deployment (gates the device-registration UI).",
|
||||
"readOnly": true
|
||||
},
|
||||
"PUSH_VAPID_PUBLIC_KEY": {
|
||||
"type": "string",
|
||||
"description": "VAPID public key (base64url) the web client passes as applicationServerKey to subscribe; null when Web Push is not configured.",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -417,7 +427,8 @@
|
||||
"FEATURE_THREAD_SPLIT",
|
||||
"FEATURE_MAILDOMAIN_MANAGE_TOTP",
|
||||
"MESSAGES_MANUAL_RETRY_MAX_AGE",
|
||||
"FRONTEND_SILENT_LOGIN_ENABLED"
|
||||
"FRONTEND_SILENT_LOGIN_ENABLED",
|
||||
"PUSH_ENABLED"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7188,16 +7199,15 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ChannelRequest"
|
||||
"$ref": "#/components/schemas/UserChannelCreateRequestRequest"
|
||||
}
|
||||
},
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ChannelRequest"
|
||||
"$ref": "#/components/schemas/UserChannelCreateRequestRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
@@ -7205,6 +7215,16 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Channel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Existing push device refreshed (idempotent re-register)."
|
||||
},
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -7213,13 +7233,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Channel created successfully. The response carries the one-time plaintext credential (api_key / secret) which is never returned again."
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data"
|
||||
},
|
||||
"403": {
|
||||
"description": "Permission denied"
|
||||
"description": "Channel created (or push device registered)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7752,6 +7766,11 @@
|
||||
"readOnly": true,
|
||||
"nullable": true
|
||||
},
|
||||
"token_hash": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
@@ -7775,6 +7794,7 @@
|
||||
"maildomain",
|
||||
"name",
|
||||
"scope_level",
|
||||
"token_hash",
|
||||
"type",
|
||||
"updated_at",
|
||||
"user"
|
||||
@@ -7843,6 +7863,11 @@
|
||||
"readOnly": true,
|
||||
"nullable": true
|
||||
},
|
||||
"token_hash": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
@@ -7874,6 +7899,7 @@
|
||||
"maildomain",
|
||||
"name",
|
||||
"scope_level",
|
||||
"token_hash",
|
||||
"type",
|
||||
"updated_at",
|
||||
"user"
|
||||
@@ -10150,6 +10176,55 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlatformEnum": {
|
||||
"enum": [
|
||||
"apns",
|
||||
"fcm",
|
||||
"web"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "* `apns` - Apple (APNs)\n* `fcm` - Google (FCM)\n* `web` - Web Push"
|
||||
},
|
||||
"PushChannelCreateRequest": {
|
||||
"type": "object",
|
||||
"description": "Schema variant of the push registration body for ``POST .../channels/``.\n\nIdentical to ``PushDeviceRegistrationSerializer`` plus the ``type``\ndiscriminator, so the polymorphic create endpoint documents the push shape\n({type:\"push\", platform, token, keys?, name?, app_version?}) alongside the\ngeneric channel shape. Validation at runtime still uses the parent.",
|
||||
"properties": {
|
||||
"platform": {
|
||||
"$ref": "#/components/schemas/PlatformEnum"
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 8192
|
||||
},
|
||||
"app_version": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"keys": {
|
||||
"$ref": "#/components/schemas/WebPushKeysRequest"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/components/schemas/PushChannelCreateTypeEnum"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"platform",
|
||||
"token",
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"PushChannelCreateTypeEnum": {
|
||||
"enum": [
|
||||
"push"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "* `push` - push"
|
||||
},
|
||||
"ReadMessageTemplate": {
|
||||
"type": "object",
|
||||
"description": "Serialize message templates with dynamic body field inclusion.\n\nBody fields (html_body, text_body, raw_body) are only included when\nexplicitly requested via the ``?bodies=`` query parameter or the\n``body_fields`` keyword argument (for nested usage).\n\nAllowed values: ``raw``, ``html``, ``text`` (comma-separated).\nMapping: ``raw`` → ``raw_body``, ``html`` → ``html_body``, ``text`` → ``text_body``.\n\nWhen neither query param nor kwarg is provided, no body field is returned.",
|
||||
@@ -11183,6 +11258,16 @@
|
||||
"slug"
|
||||
]
|
||||
},
|
||||
"UserChannelCreateRequestRequest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ChannelRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/PushChannelCreateRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"UserWithAbilities": {
|
||||
"type": "object",
|
||||
"description": "Serialize users with abilities.\nAllow to have separated OpenAPI definition for users with and without abilities.",
|
||||
@@ -11285,6 +11370,24 @@
|
||||
"full_name",
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"WebPushKeysRequest": {
|
||||
"type": "object",
|
||||
"description": "The Web Push subscription key pair (``p256dh`` and ``auth``).",
|
||||
"properties": {
|
||||
"p256dh": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"auth": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"auth",
|
||||
"p256dh"
|
||||
]
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
|
||||
@@ -2290,6 +2290,7 @@ class ChannelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
|
||||
choices=enums.ChannelScopeLevel.choices, read_only=True
|
||||
)
|
||||
last_used_at = serializers.DateTimeField(read_only=True, allow_null=True)
|
||||
token_hash = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Channel
|
||||
@@ -2304,6 +2305,7 @@ class ChannelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
|
||||
"maildomain",
|
||||
"user",
|
||||
"last_used_at",
|
||||
"token_hash",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -2314,6 +2316,7 @@ class ChannelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
|
||||
"user",
|
||||
"scope_level",
|
||||
"last_used_at",
|
||||
"token_hash",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -2352,6 +2355,21 @@ class ChannelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
|
||||
{enums.ChannelTypes.API_KEY, enums.ChannelTypes.WEBHOOK}
|
||||
)
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_token_hash(self, obj):
|
||||
"""Expose the push channel's ``lookup_hash`` (sha256 of the device
|
||||
token/endpoint) so the web client can recognise *its own* device row —
|
||||
needed to unsubscribe this browser on sign-out — without the raw
|
||||
token/endpoint ever leaving the server. The hash is preimage-resistant,
|
||||
so it discloses nothing replayable (the cross-user reclaim path already
|
||||
requires the raw token; see ``services.push.common.register_push_device``).
|
||||
Only ``push`` channels populate ``lookup_hash``; every other type
|
||||
returns ``None``.
|
||||
"""
|
||||
if obj.type == enums.ChannelTypes.PUSH:
|
||||
return obj.lookup_hash
|
||||
return None
|
||||
|
||||
def create(self, validated_data):
|
||||
# Mint the per-type secret on a transient instance so the
|
||||
# resulting ``encrypted_settings`` rides through the normal
|
||||
@@ -2687,6 +2705,26 @@ class ChannelSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
|
||||
f"Allowed types: {', '.join(allowed_types)}"
|
||||
}
|
||||
)
|
||||
# Push channels are device registrations handled by the dedicated
|
||||
# upsert path on the collection POST (``type=push``). Block
|
||||
# create/PATCH through the generic channel serializer so the
|
||||
# queryable platform / lookup_hash can't be desynced from the
|
||||
# encrypted token. (Create is also gated by the type allowlist; this
|
||||
# additionally covers the read-only-type PATCH path on an existing
|
||||
# push channel.) One exception: renaming — ``name`` is display-only
|
||||
# metadata with no sync invariant, and re-registration can't carry
|
||||
# a rename for a *remote* device.
|
||||
instance_type = getattr(self.instance, "type", None)
|
||||
if enums.ChannelTypes.PUSH in (channel_type, instance_type):
|
||||
is_rename_only = self.instance is not None and set(attrs) <= {"name"}
|
||||
if not is_rename_only:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"type": "push channels are managed via device "
|
||||
"registration (POST to this collection with "
|
||||
"type=push); only `name` may be updated."
|
||||
}
|
||||
)
|
||||
self._reject_caller_supplied_encrypted_keys(attrs)
|
||||
self._validate_api_key_scopes(attrs)
|
||||
self._validate_webhook_settings(attrs)
|
||||
@@ -3098,3 +3136,84 @@ class ThreadBulkDeleteRequestSerializer(serializers.Serializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""This serializer is only used to validate the data, not to create or update."""
|
||||
|
||||
|
||||
class WebPushKeysSerializer(serializers.Serializer):
|
||||
"""The Web Push subscription key pair (``p256dh`` and ``auth``)."""
|
||||
|
||||
# Both are typed as plain (non-blank) strings on purpose. That is enough to
|
||||
# reject the poison shape a bare DictField allowed — key *values* that
|
||||
# aren't strings (``{p256dh: {...}, auth: [...]}``), which used to be stored
|
||||
# and then fail deterministically (retrying forever) at send time. We do NOT
|
||||
# validate the base64url encoding or byte lengths: browsers are the only
|
||||
# web-push clients and always emit well-formed keys, so length checks would
|
||||
# be a footgun (rejecting a future non-standard-but-valid client) for no
|
||||
# real gain — a genuinely undeliverable key is now handled gracefully at
|
||||
# send time (marked stale, never retried; see ``services/push/webpush.py``).
|
||||
# Declaring exactly these two fields also means only they get stored.
|
||||
p256dh = serializers.CharField()
|
||||
auth = serializers.CharField()
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Input-only nested serializer; never persisted directly."""
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Input-only nested serializer; never persisted directly."""
|
||||
|
||||
|
||||
class PushDeviceRegistrationSerializer(serializers.Serializer):
|
||||
"""Validate a mobile/web device push registration.
|
||||
|
||||
Input for the push branch of ``UserChannelViewSet.create`` (a POST with
|
||||
``type=push``): the platform, the opaque push token, and optional client
|
||||
metadata. The viewset turns this into a user-scoped ``push`` Channel (token
|
||||
stored encrypted). ``keys`` carries the Web Push p256dh/auth pair; unused
|
||||
for native platforms.
|
||||
"""
|
||||
|
||||
platform = serializers.ChoiceField(choices=enums.PushPlatformChoices.choices)
|
||||
token = serializers.CharField(max_length=8192, trim_whitespace=False)
|
||||
app_version = serializers.CharField(max_length=64, required=False, allow_blank=True)
|
||||
keys = WebPushKeysSerializer(required=False)
|
||||
name = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
|
||||
def validate_token(self, value):
|
||||
"""Validate the token is not an empty value."""
|
||||
if not value.strip():
|
||||
raise serializers.ValidationError("token must not be empty")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Web Push needs the subscription keys, or the device can never be
|
||||
delivered to — reject at registration rather than silently accepting a
|
||||
web device that gets no pushes. Non-web platforms don't use keys.
|
||||
|
||||
Only the presence of the pair is enforced here; the nested
|
||||
``WebPushKeysSerializer`` just types the two values as strings.
|
||||
"""
|
||||
if attrs.get("platform") == enums.PushPlatformChoices.WEB:
|
||||
if not attrs.get("keys"):
|
||||
raise serializers.ValidationError(
|
||||
{"keys": "web push requires keys.p256dh and keys.auth."}
|
||||
)
|
||||
else:
|
||||
attrs.pop("keys", None)
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Input-only serializer; the viewset performs the registration."""
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Input-only serializer; the viewset performs the registration."""
|
||||
|
||||
|
||||
class PushChannelCreateSerializer(PushDeviceRegistrationSerializer):
|
||||
"""Schema variant of the push registration body for ``POST .../channels/``.
|
||||
|
||||
Identical to ``PushDeviceRegistrationSerializer`` plus the ``type``
|
||||
discriminator, so the polymorphic create endpoint documents the push shape
|
||||
({type:"push", platform, token, keys?, name?, app_version?}) alongside the
|
||||
generic channel shape. Validation at runtime still uses the parent.
|
||||
"""
|
||||
|
||||
type = serializers.ChoiceField(choices=[enums.ChannelTypes.PUSH])
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""API ViewSet for Channel model."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiResponse,
|
||||
PolymorphicProxySerializer,
|
||||
extend_schema,
|
||||
inline_serializer,
|
||||
)
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework import serializers as drf_serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
|
||||
from core import models
|
||||
from core.enums import ChannelScopeLevel, ChannelTypes, WebhookAuthMethod
|
||||
@@ -20,6 +23,15 @@ from core.enums import ChannelScopeLevel, ChannelTypes, WebhookAuthMethod
|
||||
from .. import permissions, serializers
|
||||
|
||||
|
||||
class DeviceRegistrationThrottle(UserRateThrottle):
|
||||
"""Rate-limit device (push) registration per user.
|
||||
|
||||
Rate comes from ``DEFAULT_THROTTLE_RATES['device_registration']`` (settings).
|
||||
"""
|
||||
|
||||
scope = "device_registration"
|
||||
|
||||
|
||||
def _attach_credential(data: dict, channel: models.Channel) -> None:
|
||||
"""Add the channel's freshly-minted credential to ``data``.
|
||||
|
||||
@@ -340,3 +352,104 @@ class UserChannelViewSet(ChannelViewSet):
|
||||
"user": self.request.user,
|
||||
"scope_level": ChannelScopeLevel.USER,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_push_registration(data):
|
||||
"""Whether ``data`` is a push device-registration payload (``type=push``).
|
||||
|
||||
Guards the non-dict case: a top-level JSON body that is not an object
|
||||
(e.g. ``[]``) parses to a list, on which ``.get`` raises
|
||||
``AttributeError``. In ``get_throttles`` that fires inside
|
||||
``check_throttles`` — before the handler and the custom exception
|
||||
mapping — so it surfaces as a 500 instead of the serializer's 400.
|
||||
A non-dict body is never a push registration: treat it as not-push and
|
||||
let the standard create path reach the serializer and return 400.
|
||||
"""
|
||||
return isinstance(data, dict) and data.get("type") == ChannelTypes.PUSH
|
||||
|
||||
def get_throttles(self):
|
||||
"""Apply the device-registration throttle to push registrations only.
|
||||
|
||||
A push registration is a ``POST`` with ``type=push`` to this collection
|
||||
(the device-registration upsert); it re-fires on every cold launch, so it
|
||||
gets its own per-user rate. Every other action keeps the default throttles.
|
||||
"""
|
||||
if self.request.method == "POST" and self._is_push_registration(
|
||||
self.request.data
|
||||
):
|
||||
return [DeviceRegistrationThrottle()]
|
||||
return super().get_throttles()
|
||||
|
||||
@extend_schema(
|
||||
request=PolymorphicProxySerializer(
|
||||
component_name="UserChannelCreateRequest",
|
||||
serializers=[
|
||||
serializers.ChannelSerializer,
|
||||
serializers.PushChannelCreateSerializer,
|
||||
],
|
||||
resource_type_field_name=None,
|
||||
),
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
response=serializers.ChannelSerializer,
|
||||
description="Existing push device refreshed (idempotent re-register).",
|
||||
),
|
||||
201: OpenApiResponse(
|
||||
response=serializers.ChannelCreateResponseSerializer,
|
||||
description="Channel created (or push device registered).",
|
||||
),
|
||||
},
|
||||
)
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Create a user-scoped channel.
|
||||
|
||||
Push devices register through this same endpoint with ``type=push`` and
|
||||
the device fields ({platform, token, keys?, name?, app_version?}): rather
|
||||
than a plain create, that path is an idempotent upsert keyed on the
|
||||
token's hash (re-registering the same device updates the one row, 200; a
|
||||
new device is 201). Listing/deleting devices then goes through the normal
|
||||
list/destroy on this collection, giving device management for free. All
|
||||
other ``type`` values fall through to the standard channel create.
|
||||
"""
|
||||
if self._is_push_registration(request.data):
|
||||
return self._register_push_device(request)
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def _register_push_device(self, request):
|
||||
"""Upsert the caller's device as a user-scoped ``push`` Channel.
|
||||
|
||||
404s when push is disabled so the feature (and the token-reclaim path)
|
||||
stays dark until an operator opts in. 400s on a platform whose gateway
|
||||
has no credentials: accepting the device would enroll it into a black
|
||||
hole (its sender no-ops), and the explicit error is the client's only
|
||||
signal that this deployment doesn't serve its transport.
|
||||
"""
|
||||
if not settings.PUSH_ENABLED:
|
||||
raise NotFound()
|
||||
|
||||
from core.services.push import ( # pylint: disable=import-outside-toplevel
|
||||
gateway_configured,
|
||||
register_push_device,
|
||||
)
|
||||
|
||||
serializer = serializers.PushDeviceRegistrationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
platform = serializer.validated_data["platform"]
|
||||
if not gateway_configured(platform):
|
||||
raise ValidationError(
|
||||
{"platform": [f"Push is not configured for {platform!r} here."]}
|
||||
)
|
||||
# Stamp the registering session so a voluntary logout of *this* session
|
||||
# unregisters this device (see the ``user_logged_out`` receiver in
|
||||
# ``core.signals``). API-key/token auth paths have no session — the
|
||||
# channel is then never logout-bound, matching their lifecycle.
|
||||
session = getattr(request, "session", None)
|
||||
channel, created = register_push_device(
|
||||
user=request.user,
|
||||
session_key=getattr(session, "session_key", None),
|
||||
**serializer.validated_data,
|
||||
)
|
||||
return Response(
|
||||
self.get_serializer(channel).data,
|
||||
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -242,6 +242,28 @@ CONFIG_ENTRIES = (
|
||||
},
|
||||
required=False,
|
||||
),
|
||||
ConfigEntry(
|
||||
"PUSH_ENABLED",
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Whether push notifications are available on this "
|
||||
"deployment (gates the device-registration UI)."
|
||||
),
|
||||
},
|
||||
),
|
||||
ConfigEntry(
|
||||
"PUSH_VAPID_PUBLIC_KEY",
|
||||
{
|
||||
"type": "string",
|
||||
"description": (
|
||||
"VAPID public key (base64url) the web client passes "
|
||||
"as applicationServerKey to subscribe; null when "
|
||||
"Web Push is not configured."
|
||||
),
|
||||
},
|
||||
required=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -255,6 +255,10 @@ class ChannelTypes(StrEnum):
|
||||
WEBHOOK = "webhook"
|
||||
CALDAV = "caldav"
|
||||
IMPORT = "import"
|
||||
# A user-scoped push-notification target (one per mobile device). The
|
||||
# device's push token lives in ``encrypted_settings``; ``settings`` carries
|
||||
# the platform + a token hash for dedup/reclaim. See core.services.push.
|
||||
PUSH = "push"
|
||||
|
||||
|
||||
class ImportSource(StrEnum):
|
||||
@@ -473,6 +477,22 @@ class MessageTemplateTypeChoices(models.IntegerChoices):
|
||||
AUTOREPLY = 3, "autoreply"
|
||||
|
||||
|
||||
class PushPlatformChoices(models.TextChoices):
|
||||
"""Push delivery transports a push channel can target.
|
||||
|
||||
Named by transport, not by OS, because that is what the server keys on: the
|
||||
value selects which sender in :mod:`core.services.push` handles it. OS and
|
||||
transport are not 1:1 (a de-Googled Android uses HMS, not FCM; an iOS app on
|
||||
the Firebase SDK gets an FCM token), so the OS is a frontend display concern
|
||||
carried in the device's ``name`` — never inferred from this value. Stored as
|
||||
a short string in the push channel's ``settings.platform``.
|
||||
"""
|
||||
|
||||
APNS = "apns", "Apple (APNs)"
|
||||
FCM = "fcm", "Google (FCM)"
|
||||
WEB = "web", "Web Push"
|
||||
|
||||
|
||||
EML_SUPPORTED_MIME_TYPES = ["message/rfc822", "application/eml", "text/plain"]
|
||||
MBOX_SUPPORTED_MIME_TYPES = [
|
||||
"application/octet-stream",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Derive the VAPID public key from the configured private key.
|
||||
|
||||
Run once when setting up Web Push: it prints the base64url public key that the
|
||||
browser passes as ``applicationServerKey``. Pin the printed value in the
|
||||
``PUSH_VAPID_PUBLIC_KEY`` env var so ``/config`` can serve it without importing
|
||||
the push/crypto dependency graph on the request path.
|
||||
|
||||
``--verify`` instead checks that the *configured* ``PUSH_VAPID_PUBLIC_KEY``
|
||||
matches what this private key derives to — a fast, no-startup-cost way to catch
|
||||
the silent-failure case where the pinned public key has drifted from the private
|
||||
key (e.g. after a key rotation), which makes all web push fail VAPID checks.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from core.services.push import derive_vapid_public_key
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Print (or verify) the VAPID public key derived from the private key."""
|
||||
|
||||
help = "Derive (or --verify) the VAPID public key (base64url) from the private key."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--private-key",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"VAPID private key (PEM or base64url). Defaults to the "
|
||||
"PUSH_VAPID_PRIVATE_KEY setting."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verify",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Check that the configured PUSH_VAPID_PUBLIC_KEY matches the key "
|
||||
"derived from the private key, instead of printing it. Exits "
|
||||
"non-zero on a mismatch (or if either value is missing)."
|
||||
),
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
private_key = options["private_key"] or settings.PUSH_VAPID_PRIVATE_KEY
|
||||
if not private_key:
|
||||
raise CommandError(
|
||||
"No VAPID private key: pass --private-key or set "
|
||||
"PUSH_VAPID_PRIVATE_KEY."
|
||||
)
|
||||
|
||||
derived = derive_vapid_public_key(private_key)
|
||||
if not derived:
|
||||
raise CommandError("Could not derive a public key from the private key.")
|
||||
|
||||
if options["verify"]:
|
||||
configured = settings.PUSH_VAPID_PUBLIC_KEY
|
||||
if not configured:
|
||||
raise CommandError(
|
||||
"PUSH_VAPID_PUBLIC_KEY is not set; expected the derived value:\n"
|
||||
f" {derived}"
|
||||
)
|
||||
if configured != derived:
|
||||
raise CommandError(
|
||||
"PUSH_VAPID_PUBLIC_KEY does NOT match the private key. "
|
||||
"Web push will fail VAPID verification.\n"
|
||||
f" configured: {configured}\n"
|
||||
f" derived: {derived}"
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("PUSH_VAPID_PUBLIC_KEY matches the private key.")
|
||||
)
|
||||
return
|
||||
|
||||
self.stdout.write(derived)
|
||||
self.stderr.write(
|
||||
self.style.SUCCESS(
|
||||
"Set this value as PUSH_VAPID_PUBLIC_KEY to enable Web Push."
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Generate a fresh VAPID keypair for Web Push.
|
||||
|
||||
Run once when setting up Web Push. It prints a new base64url private key (the
|
||||
single-line form accepted by ``PUSH_VAPID_PRIVATE_KEY``) together with its
|
||||
matching ``PUSH_VAPID_PUBLIC_KEY`` — pin both env vars and the pair is
|
||||
guaranteed in sync. The private key is a secret: keep it out of logs and VCS.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.services.push import generate_vapid_keypair
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Print a fresh VAPID keypair (base64url private + public keys)."""
|
||||
|
||||
help = "Generate a new VAPID keypair (base64url) for Web Push."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
private_b64, public_b64 = generate_vapid_keypair()
|
||||
|
||||
# The keys go to stdout (pipe/capture-friendly); the guidance to stderr
|
||||
# so it never contaminates a value the operator redirects to a file.
|
||||
self.stdout.write(private_b64)
|
||||
self.stderr.write(
|
||||
self.style.SUCCESS(
|
||||
"Set the following env vars to enable Web Push:\n"
|
||||
f" PUSH_VAPID_PRIVATE_KEY={private_b64}\n"
|
||||
f" PUSH_VAPID_PUBLIC_KEY={public_b64}\n"
|
||||
"Keep the private key secret."
|
||||
)
|
||||
)
|
||||
@@ -41,6 +41,7 @@ from core.mda.inbound_pipeline import (
|
||||
build_inbound_pipeline,
|
||||
run_inbound_pipeline,
|
||||
)
|
||||
from core.services.push import enqueue_push_notifications
|
||||
|
||||
from messages.celery_app import app as celery_app
|
||||
|
||||
@@ -503,6 +504,17 @@ def process_inbound_message_task(self, inbound_message_id: str):
|
||||
"Autoreply failed for inbound message %s", inbound_message_id
|
||||
)
|
||||
|
||||
# Truly last step: fire-and-forget push now that the message is
|
||||
# fully delivered. Gated on `created_now` like every other side
|
||||
# effect above: on a dedup hit (SMTP retry, greylisting) the push
|
||||
# already fired for the original create and would otherwise re-alert
|
||||
# the device — `enqueue_push_notifications` has no idempotency of
|
||||
# its own. Spam is skipped: no point waking a device for it.
|
||||
# `enqueue_push_notifications` already no-ops when push is
|
||||
# disabled ("safe to call unconditionally"), so no extra gate here.
|
||||
if created_now and not ctx.is_spam:
|
||||
enqueue_push_notifications(inbound_msg)
|
||||
|
||||
logger.info(
|
||||
"Successfully processed inbound message %s (is_spam=%s)",
|
||||
inbound_message_id,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated for the push-device lookup_hash column.
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0033_channel_is_active'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='channel',
|
||||
name='lookup_hash',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Hash of the channel's external lookup key.",
|
||||
max_length=64,
|
||||
null=True,
|
||||
verbose_name='lookup hash',
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='channel',
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(('lookup_hash__isnull', False)),
|
||||
fields=('lookup_hash',),
|
||||
name='uniq_channel_lookup_hash',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -537,6 +537,29 @@ class Channel(BaseModel):
|
||||
help_text="Uncheck to pause this channel without deleting it.",
|
||||
)
|
||||
|
||||
# Hash of an external natural key, for indexed find-or-create when a
|
||||
# channel's identity is assigned *outside* our system rather than being its
|
||||
# own PK. Today only ``push`` uses it: the device token is issued by
|
||||
# Apple/Google/the browser, so on reinstall the client has lost any channel
|
||||
# id we minted but still holds the same token — we must look up on this hash,
|
||||
# not on the PK. Channels identified by their own PK (api_key, client-bridge,
|
||||
# webhook) leave it NULL.
|
||||
#
|
||||
# The ``uniq_channel_lookup_hash`` partial index makes it globally unique
|
||||
# whenever set (NULLs are exempt). Uniqueness *scope* is therefore chosen by
|
||||
# the caller via the hashed input, not by the index: a type wanting global
|
||||
# uniqueness hashes only the natural key (push: ``sha256(f"push:{token}")``);
|
||||
# one wanting per-user/per-mailbox uniqueness folds that id in
|
||||
# (``sha256(f"{user_id}:{key}")``); and every type MUST namespace its input
|
||||
# with a type prefix so two types can never collide on the same raw value.
|
||||
lookup_hash = models.CharField(
|
||||
"lookup hash",
|
||||
max_length=64,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Hash of the channel's external lookup key.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "messages_channel"
|
||||
verbose_name = "channel"
|
||||
@@ -585,6 +608,14 @@ class Channel(BaseModel):
|
||||
),
|
||||
name="channel_scope_level_targets",
|
||||
),
|
||||
# Globally unique external-identity hash (NULLs exempt). The scope
|
||||
# of uniqueness is encoded in the hashed input by the caller, not
|
||||
# here — see the ``lookup_hash`` field comment.
|
||||
models.UniqueConstraint(
|
||||
fields=["lookup_hash"],
|
||||
condition=Q(lookup_hash__isnull=False),
|
||||
name="uniq_channel_lookup_hash",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Mobile/web push-notification delivery.
|
||||
|
||||
This is the tail end of the inbound-delivery pipeline: once a message has
|
||||
landed, :func:`enqueue_push_notifications` is called (by the pipeline, on
|
||||
commit) to fan a *thin* notification out to every active device the
|
||||
recipient mailbox's user(s) registered.
|
||||
|
||||
Layout:
|
||||
|
||||
- :mod:`common` — result types, the thin payload, device storage/registration,
|
||||
recipient resolution, the process-global HTTP clients, stale-device deletion.
|
||||
- :mod:`apns` / :mod:`fcm` / :mod:`webpush` — the per-transport senders.
|
||||
- :mod:`tasks` — the Celery orchestrator + the per-device delivery task.
|
||||
|
||||
Design constraints baked in here:
|
||||
|
||||
- **Feature-flagged off.** Every public entry point short-circuits when
|
||||
``settings.PUSH_ENABLED`` is False, so the package is inert until an operator
|
||||
opts in. Enabling push for only one platform is safe: registration refuses
|
||||
the unconfigured platforms (:func:`gateway_configured`), and each sender
|
||||
additionally no-ops — with a deduplicated warning — should credentials
|
||||
disappear after devices enrolled.
|
||||
- **No message content on the wire.** We never put the subject, body, sender
|
||||
name or any other message *content* into the payload — only routing
|
||||
identifiers (``thread_id`` / ``message_id`` / ``mailbox_id``), a ``type`` and
|
||||
an unread *count* for the badge, then the device refetches over its
|
||||
authenticated session. See :func:`build_thin_payload`. Note this is not full
|
||||
privacy: only Web Push is end-to-end encrypted (RFC 8291) — for APNs and FCM
|
||||
those routing UUIDs and the unread count are visible to Apple/Google in
|
||||
transit. The content itself never is.
|
||||
- **Non-fatal.** Push is best-effort. Senders never raise into the caller —
|
||||
failures are logged and swallowed so a flaky gateway can never break delivery.
|
||||
- **One task per notification.** Each device's push is an independently-retryable
|
||||
Celery task; the gateways have no multi-device batch API, so the Celery worker
|
||||
pool provides the parallelism.
|
||||
- **Self-healing devices.** A gateway "this token is dead" response (APNs 410
|
||||
``Unregistered``, FCM ``UNREGISTERED`` / ``NOT_FOUND``, Web Push 404/410)
|
||||
deletes that channel — narrowly, behind two circuit-breakers, to avoid wiping
|
||||
live devices on a config error.
|
||||
"""
|
||||
|
||||
from core.enums import PushPlatformChoices
|
||||
from core.services.ssrf import SSRFSafeSession, SSRFValidationError
|
||||
|
||||
from . import apns, common, fcm, tasks, webpush
|
||||
from .apns import APNS_ALERT_LOC_KEY, send_apns
|
||||
from .common import (
|
||||
PUSH_TYPE_NEW_MESSAGE,
|
||||
PushResult,
|
||||
PushTransientError,
|
||||
build_thin_payload,
|
||||
collapse_key_for_message,
|
||||
register_push_device,
|
||||
)
|
||||
from .fcm import (
|
||||
FCM_ANDROID_CHANNEL_ID,
|
||||
FCM_BODY_LOC_KEY,
|
||||
FCM_TITLE_LOC_KEY,
|
||||
send_fcm,
|
||||
)
|
||||
from .tasks import (
|
||||
enqueue_push_notifications,
|
||||
send_push_for_message,
|
||||
send_push_notification,
|
||||
)
|
||||
from .webpush import (
|
||||
WEBPUSH_TTL_SECONDS,
|
||||
derive_vapid_public_key,
|
||||
generate_vapid_keypair,
|
||||
send_webpush,
|
||||
)
|
||||
|
||||
|
||||
def gateway_configured(platform: str) -> bool:
|
||||
"""True when the gateway serving ``platform`` has its credentials set.
|
||||
|
||||
Device registration refuses platforms this returns False for: accepting
|
||||
them would enroll a fleet whose notifications are silently dropped at send
|
||||
time (each sender no-ops without its credentials). Unknown platform values
|
||||
are treated as unconfigured.
|
||||
"""
|
||||
checks = {
|
||||
PushPlatformChoices.APNS: apns.apns_configured,
|
||||
PushPlatformChoices.FCM: fcm.fcm_configured,
|
||||
PushPlatformChoices.WEB: webpush.webpush_configured,
|
||||
}
|
||||
check = checks.get(platform)
|
||||
return bool(check and check())
|
||||
|
||||
|
||||
__all__ = [
|
||||
"APNS_ALERT_LOC_KEY",
|
||||
"FCM_ANDROID_CHANNEL_ID",
|
||||
"FCM_BODY_LOC_KEY",
|
||||
"FCM_TITLE_LOC_KEY",
|
||||
"PUSH_TYPE_NEW_MESSAGE",
|
||||
"PushResult",
|
||||
"PushTransientError",
|
||||
"SSRFSafeSession",
|
||||
"SSRFValidationError",
|
||||
"WEBPUSH_TTL_SECONDS",
|
||||
"apns",
|
||||
"build_thin_payload",
|
||||
"collapse_key_for_message",
|
||||
"common",
|
||||
"derive_vapid_public_key",
|
||||
"enqueue_push_notifications",
|
||||
"fcm",
|
||||
"gateway_configured",
|
||||
"generate_vapid_keypair",
|
||||
"register_push_device",
|
||||
"send_apns",
|
||||
"send_fcm",
|
||||
"send_push_for_message",
|
||||
"send_push_notification",
|
||||
"send_webpush",
|
||||
"tasks",
|
||||
"webpush",
|
||||
]
|
||||
@@ -0,0 +1,246 @@
|
||||
"""APNs (Apple Push Notification service) sender — token auth, HTTP/2.
|
||||
|
||||
A visible, high-priority, content-free alert that survives app termination. The
|
||||
provider token is minted once and cached (Apple throttles re-minting); requests
|
||||
go over the process-global HTTP/2 client (see :mod:`common`).
|
||||
"""
|
||||
|
||||
# pylint: disable=broad-exception-caught
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
import jwt
|
||||
|
||||
from core import models
|
||||
from core.services.push.common import (
|
||||
PushResult,
|
||||
_apns_client,
|
||||
_channel_token,
|
||||
_deactivate_stale_channels,
|
||||
_is_transient_status,
|
||||
warn_gateway_unconfigured,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
def apns_configured() -> bool:
|
||||
"""True when all APNs token-auth settings are present."""
|
||||
return bool(
|
||||
settings.PUSH_APNS_KEY
|
||||
and settings.PUSH_APNS_KEY_ID
|
||||
and settings.PUSH_APNS_TEAM_ID
|
||||
and settings.PUSH_APNS_BUNDLE_ID
|
||||
)
|
||||
|
||||
|
||||
# APNs reasons that mean "this device token is permanently dead" → deactivate.
|
||||
# Deliberately narrow: only ``Unregistered`` (which Apple pairs with a 410), the
|
||||
# unambiguous "this token is gone" signal.
|
||||
#
|
||||
# ``BadDeviceToken`` is intentionally NOT here. Apple returns it both for a
|
||||
# genuinely malformed token AND — far more commonly in practice — for a token
|
||||
# sent to the wrong environment (a production token to the sandbox gateway or
|
||||
# vice-versa, i.e. a mis-set ``PUSH_APNS_USE_SANDBOX``). Treating it as "dead"
|
||||
# would let one wrong env flag delete a user's iOS device on the first send,
|
||||
# below the mass-stale circuit-breaker's batch threshold. So we log it and keep
|
||||
# the row. Genuinely-bad tokens are still cleaned by the *regular* paths:
|
||||
# - the device re-registers with a fresh, valid token on its next app launch
|
||||
# (same-user upsert replaces the row in place), or
|
||||
# - APNs later returns 410/``Unregistered`` once it considers the token gone
|
||||
# (deleted here), or
|
||||
# - the per-user device cap (``PUSH_MAX_DEVICES_PER_USER``) evicts it as LRU
|
||||
# when newer devices register, or
|
||||
# - the user removes it by hand from device management (/users/me/channels/).
|
||||
# (DeviceTokenNotForTopic / ExpiredProviderToken are config/auth errors, not a
|
||||
# dead device, so they are likewise excluded.)
|
||||
_APNS_STALE_REASONS = frozenset({"Unregistered"})
|
||||
|
||||
# Client-side localization key for the visible APNs alert. The push carries
|
||||
# only this KEY — never message content — and the app maps it to a localized
|
||||
# string in its Localizable.strings.
|
||||
APNS_ALERT_LOC_KEY = "NEW_MESSAGE"
|
||||
|
||||
|
||||
# APNs validates one provider token for 1h and *rejects* regenerating it too
|
||||
# often (``TooManyProviderTokenUpdates`` — Apple recommends no more than once per
|
||||
# ~20min). Minting per message would trip that on a busy server, so we share one
|
||||
# token across all fan-outs via the (redis-backed) cache and refresh comfortably
|
||||
# inside the 1h hard expiry.
|
||||
APNS_TOKEN_CACHE_TTL = 45 * 60
|
||||
|
||||
|
||||
def _apns_auth_token() -> str:
|
||||
"""Return a cached ES256 provider token, minting (and caching) on a miss.
|
||||
|
||||
Shared across messages through the process cache so we don't re-mint per
|
||||
fan-out (which Apple throttles). The cache key folds in the key id plus a
|
||||
digest of the signing key, so rotating either credential yields a fresh
|
||||
token immediately rather than serving a stale one until TTL.
|
||||
"""
|
||||
key_fingerprint = hashlib.sha256(
|
||||
(settings.PUSH_APNS_KEY or "").encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
cache_key = (
|
||||
f"push:apns:provider_token:{settings.PUSH_APNS_KEY_ID}:{key_fingerprint}"
|
||||
)
|
||||
token = cache.get(cache_key)
|
||||
if token:
|
||||
return token
|
||||
token = jwt.encode(
|
||||
{"iss": settings.PUSH_APNS_TEAM_ID, "iat": int(time.time())},
|
||||
settings.PUSH_APNS_KEY,
|
||||
algorithm="ES256",
|
||||
headers={"kid": settings.PUSH_APNS_KEY_ID},
|
||||
)
|
||||
cache.set(cache_key, token, APNS_TOKEN_CACHE_TTL)
|
||||
return token
|
||||
|
||||
|
||||
def send_apns(
|
||||
items: list[tuple[models.Channel, dict]], collapse_key: str
|
||||
) -> PushResult:
|
||||
"""Send to iOS devices via APNs (token auth, HTTP/2).
|
||||
|
||||
``items`` pairs each device channel with its own thin payload (the badge
|
||||
count is per-user). A visible, high-priority alert that survives app
|
||||
termination. The provider token is cached and the HTTP/2 connection is reused
|
||||
across tasks. Devices APNs reports as ``Unregistered`` (410) are removed (via
|
||||
the circuit-breaker); transient failures (429 / 5xx / network) are counted
|
||||
for retry; other rejections are logged only. Returns a :class:`PushResult`.
|
||||
"""
|
||||
if not items:
|
||||
return PushResult()
|
||||
if not apns_configured():
|
||||
warn_gateway_unconfigured("apns")
|
||||
return PushResult()
|
||||
|
||||
try:
|
||||
auth = _apns_auth_token()
|
||||
except (ValueError, jwt.exceptions.InvalidKeyError) as exc:
|
||||
# The signing key itself is unusable, which no amount of retrying fixes:
|
||||
# count the batch as permanently failed rather than burning the retry
|
||||
# budget (5 attempts, backing off to 10min) on every device, for every
|
||||
# message, until PUSH_APNS_KEY is corrected. Both types are needed:
|
||||
# cryptography raises a bare ValueError for a malformed PEM (the common
|
||||
# case — a mis-pasted key), while PyJWT only raises InvalidKeyError for a
|
||||
# well-formed key of the wrong type (e.g. RSA where ES256 wants EC).
|
||||
# Neither message echoes the key material.
|
||||
logger.error(
|
||||
"APNs signing key is unusable (%s: %s); dropping %d notification(s). "
|
||||
"Check PUSH_APNS_KEY.",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
len(items),
|
||||
)
|
||||
return PushResult()
|
||||
except Exception as exc:
|
||||
# Anything else (e.g. the cache backend being down) is transient — the
|
||||
# whole batch is worth retrying once a fresh token is mintable again.
|
||||
# Logged with a traceback: the cause is unknown by construction.
|
||||
logger.exception("APNs provider-token mint failed: %s", exc)
|
||||
return PushResult(0, len(items))
|
||||
|
||||
host = (
|
||||
"api.sandbox.push.apple.com"
|
||||
if settings.PUSH_APNS_USE_SANDBOX
|
||||
else "api.push.apple.com"
|
||||
)
|
||||
headers = {
|
||||
"authorization": f"bearer {auth}",
|
||||
"apns-topic": settings.PUSH_APNS_BUNDLE_ID,
|
||||
"apns-push-type": "alert",
|
||||
"apns-priority": "10",
|
||||
}
|
||||
if collapse_key:
|
||||
headers["apns-collapse-id"] = collapse_key
|
||||
|
||||
delivered = 0
|
||||
transient = 0
|
||||
stale: list[models.Channel] = []
|
||||
try:
|
||||
# Process-global HTTP/2 client, reused across notification tasks.
|
||||
client = _apns_client()
|
||||
for channel, payload in items:
|
||||
token = _channel_token(channel)
|
||||
if not token:
|
||||
# Missing/undecryptable settings (e.g. after a Fernet key
|
||||
# rotation) — permanent, not transient: skip without counting a
|
||||
# retry or deleting (the row self-heals when the device
|
||||
# re-registers). Resolved before the try so it can't be
|
||||
# miscounted as a transient network failure. See
|
||||
# _channel_settings.
|
||||
logger.warning(
|
||||
"APNs channel %s has unreadable token; skipping", channel.id
|
||||
)
|
||||
continue
|
||||
# Visible, high-priority alert that survives app termination.
|
||||
# Content-free: only a localization KEY (the app renders the
|
||||
# string) plus the unread badge — never the sender or subject.
|
||||
# mutable-content lets a Notification Service Extension enrich
|
||||
# it after refetching.
|
||||
aps = {
|
||||
"alert": {"loc-key": APNS_ALERT_LOC_KEY},
|
||||
"sound": "default",
|
||||
"mutable-content": 1,
|
||||
"badge": int(payload.get("unread_count", 0)),
|
||||
}
|
||||
body = {"aps": aps, **payload}
|
||||
try:
|
||||
resp = client.post(
|
||||
f"https://{host}/3/device/{token}",
|
||||
json=body,
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Network/timeout — transient, retry.
|
||||
logger.warning("APNs send failed for channel %s: %s", channel.id, exc)
|
||||
transient += 1
|
||||
continue
|
||||
if resp.status_code == 200:
|
||||
delivered += 1
|
||||
continue
|
||||
reason = ""
|
||||
try:
|
||||
reason = resp.json().get("reason", "") or ""
|
||||
except Exception:
|
||||
logger.debug("APNs response not JSON for channel %s", channel.id)
|
||||
if resp.status_code == 410 or reason in _APNS_STALE_REASONS:
|
||||
logger.info(
|
||||
"APNs reports channel %s stale (%s)",
|
||||
channel.id,
|
||||
reason or resp.status_code,
|
||||
)
|
||||
stale.append(channel)
|
||||
elif _is_transient_status(resp.status_code):
|
||||
logger.warning(
|
||||
"APNs transient failure for channel %s: status=%s reason=%s",
|
||||
channel.id,
|
||||
resp.status_code,
|
||||
reason,
|
||||
)
|
||||
transient += 1
|
||||
else:
|
||||
logger.warning(
|
||||
"APNs rejected channel %s: status=%s reason=%s",
|
||||
channel.id,
|
||||
resp.status_code,
|
||||
reason,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The whole batch failed to even run (client setup) — transient. Logged
|
||||
# with a traceback: unlike the per-device network errors above (an
|
||||
# expected timeout, where the message says it all), reaching here means
|
||||
# something unforeseen broke and the stack is the only clue.
|
||||
logger.exception("APNs batch send failed: %s", exc)
|
||||
transient = len(items) - delivered
|
||||
|
||||
_deactivate_stale_channels(stale, len(items), platform="apns")
|
||||
return PushResult(delivered, transient)
|
||||
@@ -0,0 +1,546 @@
|
||||
"""Shared push-delivery infrastructure.
|
||||
|
||||
Result types, the thin payload, device storage (``Channel(type="push")``
|
||||
registration + management), recipient resolution, the process-global HTTP
|
||||
clients, and stale-device deactivation. The per-transport senders live in
|
||||
:mod:`apns` / :mod:`fcm` / :mod:`webpush`; the Celery tasks in :mod:`tasks`.
|
||||
"""
|
||||
|
||||
# pylint: disable=broad-exception-caught
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from functools import lru_cache
|
||||
from logging import getLogger
|
||||
from typing import NamedTuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
|
||||
import httpx
|
||||
from celery.signals import worker_process_shutdown
|
||||
|
||||
from core import models
|
||||
from core.enums import ChannelScopeLevel, ChannelTypes, PushPlatformChoices
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# Notification type marker carried in every payload so the client can route
|
||||
# without inspecting content it does not (and must not) receive here.
|
||||
PUSH_TYPE_NEW_MESSAGE = "new_message"
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def warn_gateway_unconfigured(platform: str) -> None:
|
||||
"""Warn that pushes for ``platform`` are dropped — once per process.
|
||||
|
||||
Registration refuses platforms whose gateway is unconfigured, so devices
|
||||
can only reach an unconfigured sender when credentials were removed *after*
|
||||
they enrolled. That is an operator misconfiguration worth surfacing, but a
|
||||
whole fleet would otherwise log one line per notification — hence the
|
||||
``lru_cache`` dedup (per worker process, which is throttle enough).
|
||||
"""
|
||||
logger.warning(
|
||||
"Dropping push notifications for platform %r: gateway not configured",
|
||||
platform,
|
||||
)
|
||||
|
||||
|
||||
class PushResult(NamedTuple):
|
||||
"""Outcome of a send (one device in the per-notification path).
|
||||
|
||||
``delivered`` is the number of devices the gateway accepted (2xx).
|
||||
``transient`` is the number that hit a *retryable* failure (429 / 5xx /
|
||||
network error) — as opposed to a permanent rejection (bad payload, auth) or
|
||||
a dead-token signal (handled by stale-deactivation). A non-zero ``transient``
|
||||
tells :func:`tasks.send_push_notification` to retry with backoff. (The senders
|
||||
still accept a list, so the same shape is returned for a multi-item batch.)
|
||||
"""
|
||||
|
||||
delivered: int = 0
|
||||
transient: int = 0
|
||||
|
||||
|
||||
class PushTransientError(Exception):
|
||||
"""Raised by :func:`tasks.send_push_notification` to trigger a Celery retry.
|
||||
|
||||
Signals the device hit a transient gateway failure. Retrying re-sends the
|
||||
push, which is safe: the collapse key / Topic coalesces it onto the same
|
||||
on-device notification, so a retry never stacks a duplicate.
|
||||
"""
|
||||
|
||||
|
||||
def build_thin_payload(
|
||||
message: models.Message, unread_count: int, mailbox_id=None
|
||||
) -> dict:
|
||||
"""Build the privacy-preserving payload for ``message``.
|
||||
|
||||
Deliberately content-free: only routing ids, the notification type and
|
||||
the unread badge count. The receiving app uses these to refetch the
|
||||
message over its own authenticated session — the push channel never
|
||||
carries subject/body/sender.
|
||||
|
||||
``mailbox_id`` is the recipient's mailbox the thread is read in, so the
|
||||
client can deep-link straight to ``/mailbox/{mailbox_id}/.../thread/{thread_id}``
|
||||
on tap. It is still just a routing id (no content), and is per-recipient —
|
||||
the same message yields a different ``mailbox_id`` for each notified user.
|
||||
"""
|
||||
return {
|
||||
"type": PUSH_TYPE_NEW_MESSAGE,
|
||||
"thread_id": str(message.thread_id),
|
||||
"message_id": str(message.id),
|
||||
"mailbox_id": str(mailbox_id) if mailbox_id else None,
|
||||
"unread_count": int(unread_count),
|
||||
}
|
||||
|
||||
|
||||
def collapse_key_for_message(message: models.Message) -> str:
|
||||
"""Per-thread coalescing key.
|
||||
|
||||
Used as the APNs ``apns-collapse-id`` and the FCM ``collapse_key`` so a
|
||||
burst of messages in one thread collapses to a single visible
|
||||
notification on the device rather than stacking. Keyed on the thread so
|
||||
successive messages in the same conversation supersede each other.
|
||||
"""
|
||||
return f"thread-{message.thread_id}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push channels (one user-scoped Channel of type ``push`` per device)
|
||||
#
|
||||
# The opaque device token lives encrypted in ``encrypted_settings.token`` (and,
|
||||
# for Web Push, ``encrypted_settings.keys``). ``settings`` carries the queryable
|
||||
# ``platform``; the dedup/reclaim key is ``Channel.lookup_hash`` (sha256 of the
|
||||
# ``push:``-prefixed token, see ``_token_hash``) — an indexed, globally-unique
|
||||
# column, so we never put the token itself in a queryable column.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _token_hash(token: str) -> str:
|
||||
# Namespace the input with a ``push:`` prefix (like ``session_hash`` uses
|
||||
# ``sess:``) so the globally-unique ``lookup_hash`` can never collide with
|
||||
# another channel type that hashes the same raw value — see the field's
|
||||
# comment in models.py. This is the sole writer of push ``lookup_hash``.
|
||||
return hashlib.sha256(f"push:{token}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def session_hash(session_key: str) -> str:
|
||||
"""Hash of the Django session key a device registered under.
|
||||
|
||||
Stored in the channel's plaintext ``settings`` (it is preimage-resistant, so
|
||||
it discloses nothing usable — unlike the raw key, which would allow session
|
||||
hijacking). Its single purpose is the *voluntary logout* teardown: the
|
||||
``user_logged_out`` receiver deletes the push channels whose stored hash
|
||||
matches the session being destroyed, so only the device that logged out
|
||||
stops receiving. A session that merely *expires* never passes through the
|
||||
logout view, so its channels survive — by design, notifications outlive
|
||||
session expiry and only stop on explicit logout.
|
||||
"""
|
||||
return hashlib.sha256(f"sess:{session_key}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _channel_settings(channel: models.Channel) -> dict | None:
|
||||
"""Return the channel's decrypted settings dict, or ``None`` if unreadable.
|
||||
|
||||
``encrypted_settings`` normally decrypts to a dict, but after a Fernet key
|
||||
rotation that leaves a row undecryptable, ``EncryptedJSONField.to_python``
|
||||
swallows the ``InvalidToken`` and hands back the *raw JSON string* instead.
|
||||
Reading it as a dict then raises ``AttributeError`` — a PERMANENT failure
|
||||
(it repeats identically on every retry). Collapsing that to ``None`` here
|
||||
gives all three senders one uniform contract: an unreadable device is
|
||||
skipped (logged), never counted transient (no futile retries) and never
|
||||
deleted (a key rotation is recoverable — the row self-heals when the device
|
||||
re-registers with fresh settings on its next launch).
|
||||
"""
|
||||
return (
|
||||
channel.encrypted_settings
|
||||
if isinstance(channel.encrypted_settings, dict)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _channel_token(channel: models.Channel) -> str | None:
|
||||
return (_channel_settings(channel) or {}).get("token")
|
||||
|
||||
|
||||
def _channel_keys(channel: models.Channel) -> dict | None:
|
||||
return (_channel_settings(channel) or {}).get("keys")
|
||||
|
||||
|
||||
# Fallback device names when the client registers without one. Best-effort only
|
||||
# — the real, OS-specific label ("Eliane's iPhone") is supplied by the client.
|
||||
_DEFAULT_DEVICE_NAMES = {
|
||||
PushPlatformChoices.APNS: "Apple device",
|
||||
PushPlatformChoices.FCM: "Android device",
|
||||
PushPlatformChoices.WEB: "Web browser",
|
||||
}
|
||||
|
||||
|
||||
def _default_device_name(platform: str) -> str:
|
||||
return _DEFAULT_DEVICE_NAMES.get(platform, "Push device")
|
||||
|
||||
|
||||
def _drop_other_users_token(user: models.User, token_hash: str) -> None:
|
||||
"""Delete any push channel for this token owned by a *different* user.
|
||||
|
||||
First step of the cross-user reclaim: the caller then upserts a fresh row.
|
||||
Isolated so the concurrent-reclaim retry can rerun exactly this step (a
|
||||
foreign row that commits between our delete and our create is only removable
|
||||
by redoing the delete).
|
||||
"""
|
||||
models.Channel.objects.filter(
|
||||
type=ChannelTypes.PUSH, lookup_hash=token_hash
|
||||
).exclude(user=user).delete()
|
||||
|
||||
|
||||
def register_push_device(
|
||||
*,
|
||||
user: models.User,
|
||||
platform: str,
|
||||
token: str,
|
||||
app_version: str | None = None,
|
||||
keys: dict | None = None,
|
||||
name: str | None = None,
|
||||
session_key: str | None = None,
|
||||
):
|
||||
"""Register (or refresh) a user's device as a ``push`` Channel.
|
||||
|
||||
Device-bound semantics: a physical push token belongs to whoever currently
|
||||
registers it. ``Channel.lookup_hash`` (sha256 of the ``push:``-prefixed
|
||||
token) is globally unique. Re-registering by the *same* user updates in place
|
||||
(stable id); a *different* user's row for the same token is deleted and a
|
||||
fresh channel is created for the caller (new id, no carried-over fields). The
|
||||
token is stored encrypted. Returns ``(channel, created)`` — ``created`` is
|
||||
True for a cross-user reclaim, since the caller gets a new row.
|
||||
|
||||
``session_key`` stamps the channel with the registering session (hashed —
|
||||
see ``session_hash``) so a *voluntary logout* of that session unregisters
|
||||
this device and only this device. Clients re-register on every app load, so
|
||||
the stamp tracks the current session across key rotations.
|
||||
"""
|
||||
token_hash = _token_hash(token)
|
||||
|
||||
settings_data: dict = {"platform": platform}
|
||||
if app_version:
|
||||
settings_data["app_version"] = app_version
|
||||
if session_key:
|
||||
settings_data["session_hash"] = session_hash(session_key)
|
||||
encrypted: dict = {"token": token}
|
||||
if keys:
|
||||
encrypted["keys"] = keys
|
||||
|
||||
# Cross-user reclaim is a DELETE, not a reassign: if A logs out and B logs in
|
||||
# on the same device, the OS may reissue the same token, so we drop A's row
|
||||
# entirely and let B get a brand-new channel below. Reassigning the row in
|
||||
# place would carry A's id, created_at and (if B sends no name) A's device
|
||||
# label over to B — a small but real info leak. Same-user re-registration
|
||||
# (relaunch / token rotation) keeps its row and id via the update path.
|
||||
#
|
||||
# Accepted risk: the reclaim is authorized only by presenting a raw token
|
||||
# that hashes to the victim's lookup_hash — no proof of device control. An
|
||||
# authenticated user holding another user's *raw* token can thus evict that
|
||||
# user's channel. We accept it because the hash is preimage-resistant (a
|
||||
# column/DB leak does not enable this; the raw token lives only in
|
||||
# encrypted_settings + on the device), and the impact is a self-healing
|
||||
# notification DoS (the victim's device re-registers on next launch) with no
|
||||
# content disclosure. See docs/push-notifications.md §9.
|
||||
common = {
|
||||
"type": ChannelTypes.PUSH,
|
||||
"scope_level": ChannelScopeLevel.USER,
|
||||
"user": user,
|
||||
"settings": settings_data,
|
||||
"encrypted_settings": encrypted,
|
||||
"last_used_at": timezone.now(),
|
||||
}
|
||||
# ``defaults`` (update path) omits ``name``: the client re-sends its
|
||||
# auto-derived label on every launch, so adopting it here would silently undo
|
||||
# the user's per-device rename (PATCH) on the next refresh. The client label
|
||||
# is only a default — ``create_defaults`` sets it on first registration
|
||||
# (``name`` is a required field, so it must be present when the row is first
|
||||
# saved).
|
||||
create_defaults = {**common, "name": name or _default_device_name(platform)}
|
||||
|
||||
def _reclaim_and_upsert():
|
||||
# Drop any foreign row for this token, then upsert the caller's own. The
|
||||
# lookup is scoped by ``user`` (not ``lookup_hash`` alone) on purpose: a
|
||||
# foreign row that slips past the delete can then never resolve to a
|
||||
# silent in-place update (which would carry the other user's id,
|
||||
# created_at and device label over to the caller). Instead the get misses,
|
||||
# the create conflicts on the unique ``lookup_hash`` index, and we retry.
|
||||
_drop_other_users_token(user, token_hash)
|
||||
return models.Channel.objects.update_or_create(
|
||||
lookup_hash=token_hash,
|
||||
user=user,
|
||||
defaults=common,
|
||||
create_defaults=create_defaults,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
channel, created = _reclaim_and_upsert()
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# A different user's first-time registration of the same token
|
||||
# committed between our delete and our create. full_clean raises
|
||||
# ValidationError before the INSERT (or, in the narrower TOCTOU window
|
||||
# after full_clean's uniqueness SELECT, the DB raises IntegrityError);
|
||||
# either way the transaction stays usable. Redo the reclaim — the
|
||||
# delete now removes that freshly-committed row — so the outcome is a
|
||||
# delete + fresh create, never an in-place update.
|
||||
channel, created = _reclaim_and_upsert()
|
||||
if created:
|
||||
_prune_excess_devices(user, keep_id=channel.id)
|
||||
return channel, created
|
||||
|
||||
|
||||
def _prune_excess_devices(user: models.User, *, keep_id) -> None:
|
||||
"""Cap one user's device fleet at PUSH_MAX_DEVICES_PER_USER.
|
||||
|
||||
Called after a *new* device is registered: if the user is now over the cap,
|
||||
delete the least-recently-used surplus (oldest ``last_used_at`` first). Backs
|
||||
the loose registration throttle with a hard ceiling on persistent rows. The
|
||||
just-registered device is always kept.
|
||||
"""
|
||||
cap = settings.PUSH_MAX_DEVICES_PER_USER
|
||||
if not cap or cap <= 0:
|
||||
return
|
||||
# Push rows always have last_used_at set (registration stamps it), so
|
||||
# "-last_used_at" reliably orders most-recently-active first; we keep the
|
||||
# first ``cap`` and prune the rest.
|
||||
device_ids = list(
|
||||
models.Channel.objects.filter(type=ChannelTypes.PUSH, user=user)
|
||||
.order_by("-last_used_at")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
surplus = device_ids[cap:]
|
||||
surplus = [cid for cid in surplus if cid != keep_id]
|
||||
if surplus:
|
||||
models.Channel.objects.filter(id__in=surplus).delete()
|
||||
|
||||
|
||||
def _push_channels_for_users(user_ids) -> list[models.Channel]:
|
||||
"""All push channels for the given users, in one query."""
|
||||
return list(
|
||||
models.Channel.objects.filter(
|
||||
type=ChannelTypes.PUSH,
|
||||
scope_level=ChannelScopeLevel.USER,
|
||||
user_id__in=list(user_ids),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recipient resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _recipient_users(message: models.Message) -> list[models.User]:
|
||||
"""Return the distinct users who should be notified about ``message``.
|
||||
|
||||
These are the users with any access to a mailbox that has access to the
|
||||
message's thread — i.e. the inboxes in which this message is now
|
||||
visible. The message's own sender is excluded: a user never needs a push
|
||||
for a message they just sent.
|
||||
"""
|
||||
user_qs = models.User.objects.filter(
|
||||
mailbox_accesses__mailbox__thread_accesses__thread_id=message.thread_id,
|
||||
).distinct()
|
||||
if message.sender_user_id:
|
||||
user_qs = user_qs.exclude(id=message.sender_user_id)
|
||||
return list(user_qs)
|
||||
|
||||
|
||||
def _mailbox_by_user_for_thread(message: models.Message, user_ids) -> dict:
|
||||
"""Map each recipient user to a mailbox the thread is visible in (one query).
|
||||
|
||||
Used to put a deep-link target in the per-user payload. A user may reach the
|
||||
thread through more than one mailbox; we pick one deterministically (lowest
|
||||
id) — any is a valid landing inbox for the tap. Returns ``{user_id: mailbox_id}``.
|
||||
"""
|
||||
rows = (
|
||||
models.Mailbox.objects.filter(
|
||||
thread_accesses__thread_id=message.thread_id,
|
||||
accesses__user_id__in=list(user_ids),
|
||||
)
|
||||
.values_list("accesses__user_id", "id")
|
||||
.order_by("id")
|
||||
)
|
||||
mapping: dict = {}
|
||||
for user_id, mailbox_id in rows:
|
||||
mapping.setdefault(user_id, mailbox_id)
|
||||
return mapping
|
||||
|
||||
|
||||
def _unread_counts_for_users(user_ids) -> dict:
|
||||
"""Badge counts (distinct unread threads) for many users in ONE query.
|
||||
|
||||
A thread is unread when it has never been read, or has a message newer than
|
||||
the last read. Returns ``{user_id: count}``; best-effort (empty on error).
|
||||
"""
|
||||
user_ids = list(user_ids)
|
||||
if not user_ids:
|
||||
return {}
|
||||
try:
|
||||
# Reuse the canonical unread predicate so the push badge can't drift
|
||||
# from the in-app unread count (see ThreadAccess.unread_filter).
|
||||
rows = (
|
||||
models.ThreadAccess.objects.filter(
|
||||
mailbox__accesses__user_id__in=user_ids,
|
||||
)
|
||||
.filter(models.ThreadAccess.unread_filter())
|
||||
.values("mailbox__accesses__user_id")
|
||||
.annotate(n=Count("thread_id", distinct=True))
|
||||
)
|
||||
return {r["mailbox__accesses__user_id"]: r["n"] for r in rows}
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to compute unread counts: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared HTTP plumbing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Per-request timeout (seconds) for outbound push HTTP calls.
|
||||
PUSH_HTTP_TIMEOUT = 10.0
|
||||
|
||||
|
||||
def _is_transient_status(status_code: int) -> bool:
|
||||
"""True for HTTP statuses worth retrying: rate-limit (429) and server (5xx).
|
||||
|
||||
Permanent client errors (400/401/403/404/413) are NOT transient — retrying
|
||||
them just repeats a request the gateway will keep rejecting.
|
||||
"""
|
||||
return status_code == 429 or 500 <= status_code <= 599
|
||||
|
||||
|
||||
# Process-global HTTP clients, reused across notification tasks.
|
||||
#
|
||||
# Delivery is one Celery task per push, but opening a fresh TLS connection per
|
||||
# push is wasteful — and for APNs specifically, Apple penalizes rapid
|
||||
# connect/disconnect (it reads as abuse) and HTTP/2 setup is comparatively
|
||||
# expensive. So we keep one client (and its kept-alive connection pool) per
|
||||
# worker *process* for the gateways that talk to a single host: APNs (HTTP/2,
|
||||
# multiplexed) and FCM (HTTP/1.1, keep-alive). Web Push can't share a client —
|
||||
# each subscription is a different push-service host, delivered through a
|
||||
# per-request SSRF-IP-pinned session — so it stays per-call.
|
||||
_APNS_CLIENT: httpx.Client | None = None
|
||||
_FCM_CLIENT: httpx.Client | None = None
|
||||
|
||||
|
||||
def _apns_client() -> httpx.Client:
|
||||
"""Return the process-global APNs HTTP/2 client, creating it on first use.
|
||||
|
||||
Lazy init is safe under the default prefork pool (one task per process at a
|
||||
time). A race under a threaded pool would at worst leak one extra client.
|
||||
"""
|
||||
global _APNS_CLIENT # noqa: PLW0603 # pylint: disable=global-statement
|
||||
if _APNS_CLIENT is None:
|
||||
_APNS_CLIENT = httpx.Client(http2=True, timeout=PUSH_HTTP_TIMEOUT)
|
||||
return _APNS_CLIENT
|
||||
|
||||
|
||||
def _fcm_client() -> httpx.Client:
|
||||
"""Return the process-global FCM HTTP/1.1 client, creating it on first use."""
|
||||
global _FCM_CLIENT # noqa: PLW0603 # pylint: disable=global-statement
|
||||
if _FCM_CLIENT is None:
|
||||
_FCM_CLIENT = httpx.Client(timeout=PUSH_HTTP_TIMEOUT)
|
||||
return _FCM_CLIENT
|
||||
|
||||
|
||||
@worker_process_shutdown.connect
|
||||
def _close_push_clients(**_kwargs):
|
||||
"""Close the shared clients when a worker process shuts down."""
|
||||
global _APNS_CLIENT, _FCM_CLIENT # noqa: PLW0603 # pylint: disable=global-statement
|
||||
for client in (_APNS_CLIENT, _FCM_CLIENT):
|
||||
if client is not None:
|
||||
try:
|
||||
client.close()
|
||||
except Exception as exc:
|
||||
logger.debug("Error closing push HTTP client: %s", exc)
|
||||
_APNS_CLIENT = None
|
||||
_FCM_CLIENT = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stale-device deactivation (shared by all senders)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Defence in depth against the deletion path itself: a gateway reporting that a
|
||||
# large share of one batch is "stale" is far more likely a systemic error on our
|
||||
# side (bad payload, wrong auth) than a real fleet of dead tokens. Above this
|
||||
# ratio (on a batch large enough to be meaningful) we refuse to delete and log
|
||||
# loudly, so no single bug can wipe a platform's registrations. The primary
|
||||
# guard is still the narrow per-provider stale codes; this is the backstop.
|
||||
# Below MIN_BATCH the ratio check is skipped on purpose: a tiny fan-out can't
|
||||
# distinguish "systemic" from "genuinely dead", and the narrow codes
|
||||
# (UNREGISTERED / 410 / 404-gone) are device-dead signals, not transient — so on
|
||||
# a 1-3 device user we trust them and delete.
|
||||
STALE_DELETE_RATIO_LIMIT = 0.5
|
||||
STALE_DELETE_MIN_BATCH = 4
|
||||
|
||||
# Second backstop, for the one-task-per-notification path where there is no
|
||||
# batch to ratio-check (each task deactivates at most its own single device): a
|
||||
# rolling per-platform cap on how many devices we'll delete in a short window.
|
||||
# A systemic fault (wrong env, auth rot) would otherwise wipe a fleet one task
|
||||
# at a time, invisibly to the per-batch ratio guard. The limit is well above any
|
||||
# plausible genuine churn for a single deployment in a minute, so it only trips
|
||||
# on a runaway. Hardcoded (no operator knob) — the default is the only sane value.
|
||||
STALE_DELETE_WINDOW_SECONDS = 60
|
||||
STALE_DELETE_WINDOW_LIMIT = 500
|
||||
|
||||
|
||||
def _stale_delete_within_window(platform: str, count: int) -> bool:
|
||||
"""True if deleting ``count`` more ``platform`` devices stays under the cap.
|
||||
|
||||
Uses an atomic per-platform counter in the shared cache with a rolling TTL.
|
||||
On any cache error we fail *open* (allow the delete) — the narrow stale codes
|
||||
and the per-batch ratio guard are the primary protections; this is a backstop.
|
||||
"""
|
||||
cache_key = f"push:stale_deletes:{platform}"
|
||||
try:
|
||||
cache.add(cache_key, 0, STALE_DELETE_WINDOW_SECONDS)
|
||||
total = cache.incr(cache_key, count)
|
||||
except Exception as exc:
|
||||
logger.warning("Stale-delete window counter unavailable: %s", exc)
|
||||
return True
|
||||
return total <= STALE_DELETE_WINDOW_LIMIT
|
||||
|
||||
|
||||
def _deactivate_stale_channels(
|
||||
stale: list[models.Channel], attempted: int, *, platform: str
|
||||
) -> int:
|
||||
"""Delete channels a provider reported as permanently gone, with two guards.
|
||||
|
||||
Returns how many channels were deactivated (0 if a circuit-breaker tripped).
|
||||
"""
|
||||
if not stale:
|
||||
return 0
|
||||
if (
|
||||
attempted >= STALE_DELETE_MIN_BATCH
|
||||
and len(stale) / attempted >= STALE_DELETE_RATIO_LIMIT
|
||||
):
|
||||
logger.error(
|
||||
"Refusing to delete %d/%d %s push channels reported stale in one run "
|
||||
"— treating as a systemic error, not dead tokens.",
|
||||
len(stale),
|
||||
attempted,
|
||||
platform,
|
||||
)
|
||||
return 0
|
||||
if not _stale_delete_within_window(platform, len(stale)):
|
||||
logger.error(
|
||||
"Refusing to delete %d %s push channel(s): more than %d stale "
|
||||
"deletions in %ds — treating as a systemic error, not dead tokens.",
|
||||
len(stale),
|
||||
platform,
|
||||
STALE_DELETE_WINDOW_LIMIT,
|
||||
STALE_DELETE_WINDOW_SECONDS,
|
||||
)
|
||||
return 0
|
||||
models.Channel.objects.filter(id__in=[c.id for c in stale]).delete()
|
||||
return len(stale)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""FCM (Firebase Cloud Messaging) HTTP v1 sender.
|
||||
|
||||
One POST per device (the v1 API is single-recipient) over the process-global
|
||||
HTTP/1.1 client; the OAuth token is cached. Carries a content-free, OS-localized
|
||||
notification block so Android renders a banner even when the app is force-quit.
|
||||
"""
|
||||
|
||||
# pylint: disable=broad-exception-caught
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2 import service_account
|
||||
|
||||
from core import models
|
||||
from core.services.push.common import (
|
||||
PushResult,
|
||||
_channel_token,
|
||||
_deactivate_stale_channels,
|
||||
_fcm_client,
|
||||
_is_transient_status,
|
||||
warn_gateway_unconfigured,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# Android loc-keys the app resolves from its strings.xml. Content-free contract,
|
||||
# like the APNs alert — only keys, never content.
|
||||
FCM_TITLE_LOC_KEY = "new_message_title"
|
||||
FCM_BODY_LOC_KEY = "new_message_body"
|
||||
|
||||
# Android notification channel the Capacitor app creates at enable/refresh time
|
||||
# (features/native/push.ts) and that the manifest declares as FCM default.
|
||||
# Without an explicit channel Android 8+ renders on the SDK's "Miscellaneous"
|
||||
# fallback at DEFAULT importance — no heads-up banner, whatever the message
|
||||
# priority says. Must stay in sync with ANDROID_NOTIFICATION_CHANNEL_ID on the
|
||||
# frontend (contract-tested on both sides).
|
||||
FCM_ANDROID_CHANNEL_ID = "new_messages"
|
||||
|
||||
# FCM OAuth access tokens are ~1h-lived. Cache one across fan-outs (keyed on a
|
||||
# digest of the service-account JSON, so rotating credentials refreshes it)
|
||||
# rather than running the service-account → OAuth exchange on every message.
|
||||
FCM_TOKEN_CACHE_TTL = 45 * 60
|
||||
|
||||
|
||||
def fcm_configured() -> bool:
|
||||
"""True when FCM credentials + project id are present."""
|
||||
return bool(settings.PUSH_FCM_CREDENTIALS and settings.PUSH_FCM_PROJECT_ID)
|
||||
|
||||
|
||||
def _fcm_access_token() -> str | None:
|
||||
"""Return a cached OAuth token for FCM, minting one on a cache miss.
|
||||
|
||||
Returns ``None`` (and logs) on any credential/refresh error so the caller
|
||||
treats FCM as unavailable rather than failing the send. The token is shared
|
||||
across messages through the cache so we don't re-run the OAuth exchange per
|
||||
fan-out.
|
||||
"""
|
||||
creds_fingerprint = hashlib.sha256(
|
||||
(settings.PUSH_FCM_CREDENTIALS or "").encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
cache_key = f"push:fcm:access_token:{creds_fingerprint}"
|
||||
token = cache.get(cache_key)
|
||||
if token:
|
||||
return token
|
||||
try:
|
||||
info = json.loads(settings.PUSH_FCM_CREDENTIALS)
|
||||
credentials = service_account.Credentials.from_service_account_info(
|
||||
info,
|
||||
scopes=["https://www.googleapis.com/auth/firebase.messaging"],
|
||||
)
|
||||
credentials.refresh(Request())
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to obtain FCM access token: %s", exc)
|
||||
return None
|
||||
cache.set(cache_key, credentials.token, FCM_TOKEN_CACHE_TTL)
|
||||
return credentials.token
|
||||
|
||||
|
||||
def send_fcm(items: list[tuple[models.Channel, dict]], collapse_key: str) -> PushResult:
|
||||
"""Send to Android devices via FCM HTTP v1.
|
||||
|
||||
``items`` pairs each device channel with its own thin payload. One POST per
|
||||
device over the shared client; the OAuth token is cached. The data payload
|
||||
(all string values, as v1 requires) carries no message content; a
|
||||
content-free, OS-localized ``notification`` block lets Android render a banner
|
||||
when the app is killed, and the app refetches over its session to enrich it.
|
||||
An ``UNREGISTERED`` error removes the device's push channel (via the
|
||||
circuit-breaker); transient failures (429 / 5xx / network) are counted for
|
||||
retry. Returns a :class:`PushResult`.
|
||||
"""
|
||||
if not items:
|
||||
return PushResult()
|
||||
if not fcm_configured():
|
||||
warn_gateway_unconfigured("fcm")
|
||||
return PushResult()
|
||||
|
||||
access_token = _fcm_access_token()
|
||||
if not access_token:
|
||||
# Credentials unavailable/transient (network to Google's token
|
||||
# endpoint) — worth retrying the batch.
|
||||
return PushResult(0, len(items))
|
||||
|
||||
url = (
|
||||
"https://fcm.googleapis.com/v1/projects/"
|
||||
f"{settings.PUSH_FCM_PROJECT_ID}/messages:send"
|
||||
)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
delivered = 0
|
||||
transient = 0
|
||||
stale: list[models.Channel] = []
|
||||
try:
|
||||
# Process-global client, reused across notification tasks (keep-alive to
|
||||
# the single FCM host avoids a TLS handshake per push).
|
||||
client = _fcm_client()
|
||||
for channel, payload in items:
|
||||
token = _channel_token(channel)
|
||||
if not token:
|
||||
# Missing/undecryptable settings (e.g. after a Fernet key
|
||||
# rotation) — permanent, not transient: skip without retrying or
|
||||
# deleting (the row self-heals when the device re-registers).
|
||||
# See _channel_settings.
|
||||
logger.warning(
|
||||
"FCM channel %s has unreadable token; skipping", channel.id
|
||||
)
|
||||
continue
|
||||
# FCM v1 data values must all be strings. None values are dropped
|
||||
# (FCM rejects null data values).
|
||||
data = {k: str(v) for k, v in payload.items() if v is not None}
|
||||
# Attach a content-free, OS-localized notification so Android
|
||||
# renders a banner even when the app is force-quit (data-only
|
||||
# messages are not auto-displayed then). The keys map to the app's
|
||||
# strings.xml — the push carries no sender/subject, only loc-keys +
|
||||
# the unread badge. Symmetric with the APNs alert path.
|
||||
android: dict = {
|
||||
"collapse_key": collapse_key,
|
||||
"priority": "high",
|
||||
"notification": {
|
||||
"channel_id": FCM_ANDROID_CHANNEL_ID,
|
||||
"title_loc_key": FCM_TITLE_LOC_KEY,
|
||||
"body_loc_key": FCM_BODY_LOC_KEY,
|
||||
"notification_count": int(payload.get("unread_count", 0)),
|
||||
},
|
||||
}
|
||||
body = {
|
||||
"message": {
|
||||
"token": token,
|
||||
"data": data,
|
||||
"android": android,
|
||||
}
|
||||
}
|
||||
try:
|
||||
response = client.post(url, headers=headers, json=body)
|
||||
except Exception as exc:
|
||||
logger.warning("FCM send failed for channel %s: %s", channel.id, exc)
|
||||
transient += 1
|
||||
continue
|
||||
if response.status_code == 200:
|
||||
delivered += 1
|
||||
continue
|
||||
if _fcm_response_is_stale(response):
|
||||
logger.info("FCM reports channel %s stale", channel.id)
|
||||
stale.append(channel)
|
||||
elif _is_transient_status(response.status_code):
|
||||
logger.warning(
|
||||
"FCM transient failure for channel %s: status=%s",
|
||||
channel.id,
|
||||
response.status_code,
|
||||
)
|
||||
transient += 1
|
||||
else:
|
||||
logger.warning(
|
||||
"FCM rejected channel %s: status=%s body=%s",
|
||||
channel.id,
|
||||
response.status_code,
|
||||
response.text[:500],
|
||||
)
|
||||
except Exception as exc:
|
||||
# The whole batch failed to even run (client setup) — transient.
|
||||
logger.warning("FCM batch send failed: %s", exc)
|
||||
transient = len(items) - delivered
|
||||
|
||||
_deactivate_stale_channels(stale, len(items), platform="fcm")
|
||||
return PushResult(delivered, transient)
|
||||
|
||||
|
||||
def _fcm_response_is_stale(response) -> bool:
|
||||
"""Decide whether an FCM error response means the token is dead.
|
||||
|
||||
Only ``UNREGISTERED`` (404, or in a 400's error ``details``) and ``NOT_FOUND``
|
||||
deactivate the token. ``INVALID_ARGUMENT`` is deliberately NOT treated as
|
||||
stale — FCM also returns it for a malformed *request* (a bug on our side), so
|
||||
acting on it would delete the whole fleet on one bad deploy. Everything else
|
||||
is a transient/operator error we leave the token in place for.
|
||||
"""
|
||||
if response.status_code not in (400, 404):
|
||||
return False
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
return False
|
||||
error = payload.get("error", {}) if isinstance(payload, dict) else {}
|
||||
status_str = (error.get("status") or "").upper()
|
||||
if status_str in ("UNREGISTERED", "NOT_FOUND"):
|
||||
return True
|
||||
# Only UNREGISTERED is unambiguously a dead token. INVALID_ARGUMENT is
|
||||
# deliberately NOT treated as stale: FCM also returns it for a malformed
|
||||
# *request* (a payload/schema bug on our side), so acting on it would let
|
||||
# one bad deploy delete every Android registration in the fleet.
|
||||
for detail in error.get("details", []) or []:
|
||||
if isinstance(detail, dict) and detail.get("errorCode") == "UNREGISTERED":
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Celery tasks: the recipient-resolving orchestrator and the per-device send.
|
||||
|
||||
``enqueue_push_notifications`` is called (on commit) by the delivery pipeline;
|
||||
``send_push_for_message`` resolves recipients and dispatches one
|
||||
``send_push_notification`` task per device.
|
||||
"""
|
||||
|
||||
# pylint: disable=broad-exception-caught, unused-argument
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
import core.services.push as _push
|
||||
from core import models
|
||||
from core.enums import ChannelTypes, PushPlatformChoices
|
||||
from core.services.push.common import (
|
||||
PushTransientError,
|
||||
_mailbox_by_user_for_thread,
|
||||
_push_channels_for_users,
|
||||
_recipient_users,
|
||||
_unread_counts_for_users,
|
||||
build_thin_payload,
|
||||
collapse_key_for_message,
|
||||
)
|
||||
|
||||
from messages.celery_app import app as celery_app
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# Maps each platform to the *name* of its sender. Resolved by name at call time
|
||||
# against the package namespace (``_push``) rather than captured here, so the
|
||||
# dispatch follows monkeypatching of ``push.send_apns`` (used by the tests) and
|
||||
# stays a one-line change to extend.
|
||||
_PLATFORM_SENDER_NAMES = {
|
||||
PushPlatformChoices.APNS: "send_apns",
|
||||
PushPlatformChoices.FCM: "send_fcm",
|
||||
PushPlatformChoices.WEB: "send_webpush",
|
||||
}
|
||||
|
||||
|
||||
def enqueue_push_notifications(message: models.Message) -> None:
|
||||
"""Schedule push delivery for ``message`` after the current transaction commits.
|
||||
|
||||
Safe to call unconditionally from the delivery pipeline: it no-ops when
|
||||
push is disabled, and otherwise defers the actual send to a Celery task
|
||||
via ``transaction.on_commit`` so we never push for a message that ends
|
||||
up rolled back. Never raises.
|
||||
"""
|
||||
if not settings.PUSH_ENABLED:
|
||||
return
|
||||
message_id = str(message.id)
|
||||
|
||||
def _publish():
|
||||
# Contain broker failures *inside* the callback: it runs at commit
|
||||
# time, off this stack, so an exception escaping here would surface in
|
||||
# whoever triggered the commit — the inbound pipeline, which by then has
|
||||
# already deleted its queue row and would retry a row that is gone.
|
||||
try:
|
||||
send_push_for_message.delay(message_id) # pylint: disable=no-member
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to enqueue push for message %s: %s", message_id, exc)
|
||||
|
||||
try:
|
||||
transaction.on_commit(_publish)
|
||||
except Exception as exc:
|
||||
# on_commit can only fail in pathological setups (e.g. no DB
|
||||
# connection); push is best-effort so we swallow it.
|
||||
logger.warning("Failed to schedule push for message %s: %s", message_id, exc)
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def send_push_for_message(self, message_id: str):
|
||||
"""Resolve recipients for ``message_id`` and dispatch one task per device.
|
||||
|
||||
Loads the message, resolves the recipient users and their devices, builds
|
||||
each user's thin payload (the badge count is per-user), then dispatches one
|
||||
:func:`send_push_notification` task **per device**. This task is the
|
||||
*orchestrator*: it does the per-recipient DB work once (channels, badge
|
||||
counts, deep-link mailboxes — a handful of batched queries) and dispatches;
|
||||
it never contacts a gateway itself, so a flaky provider can't stall
|
||||
recipient resolution. Never raises: a push problem can't disrupt anything
|
||||
upstream.
|
||||
|
||||
One task per notification makes each push an independently-retryable atomic
|
||||
unit: a single flaky device retries on its own without re-sending to anyone
|
||||
else, and the Celery worker pool delivers them in parallel (the gateways
|
||||
have no multi-device batch API, so parallelism — not batching — is the
|
||||
lever). The shared, cached gateway tokens (APNs JWT / FCM OAuth) mean the
|
||||
many tasks don't each re-authenticate.
|
||||
"""
|
||||
if not settings.PUSH_ENABLED:
|
||||
return {"success": True, "skipped": "push_disabled"}
|
||||
|
||||
try:
|
||||
message = models.Message.objects.select_related("thread").get(id=message_id)
|
||||
except models.Message.DoesNotExist:
|
||||
logger.warning("send_push_for_message: message %s not found", message_id)
|
||||
return {"success": False, "error": "message_not_found"}
|
||||
|
||||
users = _recipient_users(message)
|
||||
if not users:
|
||||
return {"success": True, "notified_users": 0, "dispatched": 0}
|
||||
|
||||
# One query for every recipient's push channels, grouped by user.
|
||||
channels_by_user: dict = defaultdict(list)
|
||||
for channel in _push_channels_for_users(u.id for u in users):
|
||||
channels_by_user[channel.user_id].append(channel)
|
||||
|
||||
# One query each for the recipients' badge counts and deep-link mailboxes
|
||||
# (only those with devices).
|
||||
users_with_devices = [u.id for u in users if channels_by_user.get(u.id)]
|
||||
unread_by_user = _unread_counts_for_users(users_with_devices)
|
||||
mailbox_by_user = _mailbox_by_user_for_thread(message, users_with_devices)
|
||||
|
||||
collapse_key = collapse_key_for_message(message)
|
||||
notified_users = 0
|
||||
dispatched = 0
|
||||
for user in users:
|
||||
user_channels = channels_by_user.get(user.id)
|
||||
if not user_channels:
|
||||
continue
|
||||
notified_users += 1
|
||||
# One thin payload per user (the badge count is per-user); each of the
|
||||
# user's devices gets its own task carrying that payload.
|
||||
payload = build_thin_payload(
|
||||
message,
|
||||
unread_by_user.get(user.id, 0),
|
||||
mailbox_id=mailbox_by_user.get(user.id),
|
||||
)
|
||||
for channel in user_channels:
|
||||
# Guarded per device: this task holds the only resolved recipient
|
||||
# list, so letting one broker hiccup escape would strand every
|
||||
# device still to come with no way to recover them.
|
||||
try:
|
||||
send_push_notification.delay( # pylint: disable=no-member
|
||||
str(channel.id), payload, collapse_key
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to dispatch push for channel %s: %s", channel.id, exc
|
||||
)
|
||||
continue
|
||||
dispatched += 1
|
||||
|
||||
logger.info(
|
||||
"send_push_for_message %s: notified %d user(s), dispatched %d device task(s)",
|
||||
message_id,
|
||||
notified_users,
|
||||
dispatched,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"notified_users": notified_users,
|
||||
"dispatched": dispatched,
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
autoretry_for=(PushTransientError,),
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=600,
|
||||
max_retries=5,
|
||||
retry_jitter=True,
|
||||
acks_late=True,
|
||||
)
|
||||
def send_push_notification(self, channel_id: str, payload: dict, collapse_key: str):
|
||||
"""Deliver one push to one device, retrying on a transient failure.
|
||||
|
||||
The atomic unit of delivery: it re-fetches the one channel (skips if the
|
||||
device was un-associated since dispatch), resolves its platform, and hands a
|
||||
single-item batch to that platform's sender. On a *transient* failure
|
||||
(429 / 5xx / network) it raises :class:`PushTransientError` so Celery retries
|
||||
just this notification with exponential backoff; retrying is idempotent
|
||||
on-device because the collapse key / Topic coalesces it onto the same
|
||||
notification. Dead-token devices are deleted inside the sender; permanent
|
||||
rejections (bad payload, auth) end the task. ``acks_late`` means a worker
|
||||
crash re-runs this one push (again collapse-deduped), not the whole fan-out.
|
||||
"""
|
||||
if not settings.PUSH_ENABLED:
|
||||
return {"success": True, "skipped": "push_disabled"}
|
||||
|
||||
try:
|
||||
channel = models.Channel.objects.get(id=channel_id, type=ChannelTypes.PUSH)
|
||||
except models.Channel.DoesNotExist:
|
||||
# Device un-associated (or reclaimed) between dispatch and delivery.
|
||||
return {"success": True, "skipped": "channel_gone"}
|
||||
|
||||
platform = (channel.settings or {}).get("platform")
|
||||
sender_name = _PLATFORM_SENDER_NAMES.get(platform)
|
||||
# Resolve against the package namespace so tests can monkeypatch the senders.
|
||||
sender = getattr(_push, sender_name, None) if sender_name else None
|
||||
if sender is None:
|
||||
logger.warning("No push sender for platform %r", platform)
|
||||
return {"success": False, "error": "no_sender"}
|
||||
|
||||
try:
|
||||
result = sender([(channel, payload)], collapse_key)
|
||||
except Exception as exc:
|
||||
# Senders swallow their own errors; a bug here must not crash the task
|
||||
# into an infinite retry, so we catch and stop (logged for visibility).
|
||||
logger.exception("Push sender for platform %s raised: %s", platform, exc)
|
||||
return {"success": False, "error": "sender_raised"}
|
||||
|
||||
if result.transient:
|
||||
raise PushTransientError(
|
||||
f"{platform} channel {channel_id} hit a transient failure"
|
||||
)
|
||||
|
||||
return {"success": True, "delivered": result.delivered}
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Web Push (VAPID + aes128gcm) sender and VAPID helpers.
|
||||
|
||||
The aes128gcm flow is built directly: VAPID JWT via ``py-vapid``, payload
|
||||
encryption via ``http-ece``. Delivery goes through :class:`SSRFSafeSession`
|
||||
because the subscription endpoint is client-supplied. A web channel stores the
|
||||
endpoint in ``encrypted_settings.token`` and the ``keys`` (p256dh/auth) in
|
||||
``encrypted_settings.keys``.
|
||||
"""
|
||||
|
||||
# pylint: disable=broad-exception-caught
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from logging import getLogger
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import http_ece
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from py_vapid import Vapid02
|
||||
|
||||
from core import models
|
||||
from core.services.push.common import (
|
||||
PUSH_HTTP_TIMEOUT,
|
||||
PushResult,
|
||||
_channel_keys,
|
||||
_channel_token,
|
||||
_deactivate_stale_channels,
|
||||
_is_transient_status,
|
||||
warn_gateway_unconfigured,
|
||||
)
|
||||
from core.services.ssrf import SSRFSafeSession, SSRFValidationError
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# Web Push TTL: how long the push service holds an undelivered message for an
|
||||
# offline device. The payload is only a refetch *trigger* (and a badge count),
|
||||
# so a long retention isn't useful — a trigger surfacing days later just shows a
|
||||
# misleading "new message". One day covers a phone offline overnight without
|
||||
# resurrecting stale triggers.
|
||||
WEBPUSH_TTL_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
def _web_push_topic(collapse_key: str) -> str:
|
||||
"""A Web Push ``Topic`` header derived from ``collapse_key``.
|
||||
|
||||
RFC 8030 §5.4 caps ``Topic`` at 32 chars from the URL-safe base64 alphabet;
|
||||
our ``thread-<uuid>`` collapse key is 43 chars, which push services reject
|
||||
with 400 (silently dropping the notification). Hash it to a stable 32-char
|
||||
url-safe token. APNs/FCM use the raw collapse key (their limits are larger),
|
||||
so only Web Push needs this.
|
||||
"""
|
||||
digest = hashlib.sha256(collapse_key.encode("utf-8")).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")[:32]
|
||||
|
||||
|
||||
def _valid_vapid_subject(subject) -> bool:
|
||||
"""RFC 8292 requires the VAPID ``sub`` to be a ``mailto:`` or ``https:`` URI.
|
||||
|
||||
A bare email (a common mistake) makes browsers reject the signed JWT with a
|
||||
401, so we treat a malformed subject as "not configured" rather than sending
|
||||
requests doomed to fail.
|
||||
|
||||
BLIND SPOT: this only validates the URI *scheme*, not that the contact is
|
||||
real/routable. Apple's push service (Safari) additionally validates the
|
||||
contact and rejects a scheme-valid but non-routable domain — e.g. a
|
||||
``.local``/``localhost`` address like ``mailto:admin@admin.local`` — with a
|
||||
**403**, while Chrome/FCM accept it. So a subject can pass this check and
|
||||
still break Safari *only*, with no clue beyond the 403 in the send logs.
|
||||
That case can't be caught here (it's Apple's runtime decision); configure a
|
||||
real, routable contact. ``post_setup`` in settings enforces the scheme so a
|
||||
missing/mis-schemed subject fails fast at boot rather than silently here.
|
||||
"""
|
||||
return bool(subject) and (
|
||||
subject.startswith("mailto:") or subject.startswith("https://")
|
||||
)
|
||||
|
||||
|
||||
def webpush_configured() -> bool:
|
||||
"""True when the VAPID private key + a well-formed subject are present."""
|
||||
if not (settings.PUSH_VAPID_PRIVATE_KEY and settings.PUSH_VAPID_SUBJECT):
|
||||
return False
|
||||
if not _valid_vapid_subject(settings.PUSH_VAPID_SUBJECT):
|
||||
logger.warning(
|
||||
"PUSH_VAPID_SUBJECT %r is not a mailto:/https: URI; "
|
||||
"Web Push is disabled until it is fixed.",
|
||||
settings.PUSH_VAPID_SUBJECT,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _load_vapid(private_key: str) -> Vapid02:
|
||||
"""Load a VAPID key accepting either a PEM block or a base64url raw key.
|
||||
|
||||
Operators most naturally hold the single-line base64url form (what
|
||||
``web-push``/``vapid`` and browsers emit), while ``py-vapid`` itself only
|
||||
speaks the multiline PKCS8/SEC1 PEM block. We detect the ``-----BEGIN``
|
||||
marker to route PEM to ``from_pem`` and everything else (the raw 32-byte
|
||||
scalar) to ``from_raw``, so both env-var shapes just work — as the setting's
|
||||
``help_text`` ("PEM or base64url") promises. Raises on an invalid key; the
|
||||
callers translate that into "Web Push disabled".
|
||||
"""
|
||||
key = private_key.strip()
|
||||
if "-----BEGIN" in key:
|
||||
return Vapid02.from_pem(key.encode("utf-8"))
|
||||
return Vapid02.from_raw(key.encode("utf-8"))
|
||||
|
||||
|
||||
def derive_vapid_public_key(private_key: str) -> str | None:
|
||||
"""Derive the base64url public key (P-256 point) from a VAPID private key.
|
||||
|
||||
Accepts the private key as a PEM block or a base64url raw scalar (see
|
||||
:func:`_load_vapid`). The public key is deterministic from the private key,
|
||||
so an operator never has to generate it separately — the
|
||||
``derive_vapid_public_key`` management command runs this once to print the
|
||||
value they then pin in ``PUSH_VAPID_PUBLIC_KEY``. It is intentionally *not*
|
||||
called on the request path: ``/config`` reads the configured env var
|
||||
directly so the web worker never has to import this module (and its
|
||||
push/crypto dependency graph).
|
||||
"""
|
||||
try:
|
||||
vapid = _load_vapid(private_key)
|
||||
raw = vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to derive VAPID public key: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def generate_vapid_keypair() -> tuple[str, str]:
|
||||
"""Generate a fresh VAPID keypair as ``(private_b64url, public_b64url)``.
|
||||
|
||||
Both values are base64url (unpadded): the private key is the raw 32-byte
|
||||
P-256 scalar — the single-line form accepted by ``PUSH_VAPID_PRIVATE_KEY``
|
||||
(see :func:`_load_vapid`) — and the public key is the uncompressed point for
|
||||
``PUSH_VAPID_PUBLIC_KEY``. The two are a matched pair, so an operator can pin
|
||||
both directly without a separate derivation step.
|
||||
"""
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
private_raw = key.private_numbers().private_value.to_bytes(32, "big")
|
||||
public_raw = key.public_key().public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
private_b64 = base64.urlsafe_b64encode(private_raw).rstrip(b"=").decode("ascii")
|
||||
public_b64 = base64.urlsafe_b64encode(public_raw).rstrip(b"=").decode("ascii")
|
||||
return private_b64, public_b64
|
||||
|
||||
|
||||
def _b64url_decode(value: str) -> bytes:
|
||||
"""Decode a base64url value tolerating missing padding."""
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
|
||||
|
||||
def send_webpush(
|
||||
items: list[tuple[models.Channel, dict]], collapse_key: str
|
||||
) -> PushResult:
|
||||
"""Send to web devices via the Web Push protocol (VAPID).
|
||||
|
||||
``items`` pairs each subscription channel with its own thin payload. A
|
||||
404/410 removes the subscription (via the circuit-breaker); transient
|
||||
failures (429 / 5xx / network) are counted for retry. Returns a
|
||||
:class:`PushResult`.
|
||||
|
||||
Delivery goes through :class:`SSRFSafeSession` because the endpoint is
|
||||
client-supplied at registration: it pins the resolved IP and rejects
|
||||
loopback/private/metadata targets, so a malicious subscription can't turn
|
||||
this into an SSRF against internal services.
|
||||
"""
|
||||
if not items:
|
||||
return PushResult()
|
||||
if not webpush_configured():
|
||||
warn_gateway_unconfigured("web")
|
||||
return PushResult()
|
||||
|
||||
try:
|
||||
vapid = _load_vapid(settings.PUSH_VAPID_PRIVATE_KEY)
|
||||
except Exception as exc:
|
||||
logger.warning("Invalid VAPID key; skipping Web Push: %s", exc)
|
||||
return PushResult()
|
||||
|
||||
delivered = 0
|
||||
transient = 0
|
||||
stale: list[models.Channel] = []
|
||||
for channel, payload in items:
|
||||
keys = _channel_keys(channel)
|
||||
endpoint = _channel_token(channel)
|
||||
if not keys or not endpoint:
|
||||
logger.warning(
|
||||
"Web Push channel %s missing endpoint/keys; skipping", channel.id
|
||||
)
|
||||
continue
|
||||
# Deterministic prep: decode the subscription keys and encrypt the
|
||||
# payload for this device. A failure here — malformed base64, or bytes
|
||||
# that aren't a valid P-256 point — is PERMANENT: it fails identically
|
||||
# on every retry, so the channel is dead, not transient. Registration
|
||||
# validates keys now, so this only trips on legacy/corrupted rows; mark
|
||||
# them stale instead of counting a transient (which would retry the
|
||||
# poison channel forever, 6 attempts per inbound message).
|
||||
try:
|
||||
encrypted = http_ece.encrypt(
|
||||
json.dumps(payload).encode("utf-8"),
|
||||
private_key=ec.generate_private_key(ec.SECP256R1()),
|
||||
dh=_b64url_decode(keys["p256dh"]),
|
||||
auth_secret=_b64url_decode(keys["auth"]),
|
||||
version="aes128gcm",
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.warning(
|
||||
"Web Push channel %s has undeliverable keys (%s); marking stale",
|
||||
channel.id,
|
||||
exc,
|
||||
)
|
||||
stale.append(channel)
|
||||
continue
|
||||
|
||||
try:
|
||||
origin = "{u.scheme}://{u.netloc}".format(u=urlparse(endpoint))
|
||||
headers = {
|
||||
"content-encoding": "aes128gcm",
|
||||
"ttl": str(WEBPUSH_TTL_SECONDS),
|
||||
"urgency": "high",
|
||||
**vapid.sign(
|
||||
{
|
||||
"aud": origin,
|
||||
"sub": settings.PUSH_VAPID_SUBJECT,
|
||||
"exp": int(time.time()) + 12 * 3600,
|
||||
}
|
||||
),
|
||||
}
|
||||
if collapse_key:
|
||||
headers["topic"] = _web_push_topic(collapse_key)
|
||||
resp = SSRFSafeSession().post(
|
||||
endpoint,
|
||||
timeout=int(PUSH_HTTP_TIMEOUT),
|
||||
data=encrypted,
|
||||
headers=headers,
|
||||
)
|
||||
except SSRFValidationError as exc:
|
||||
# Endpoint resolves to an internal/blocked address — never deliver,
|
||||
# but don't delete (could be transient DNS) and don't retry (the
|
||||
# endpoint is structurally unsafe); just log and skip.
|
||||
logger.warning(
|
||||
"Web Push endpoint for channel %s blocked by SSRF guard: %s",
|
||||
channel.id,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
except Exception as exc:
|
||||
# Network error (encryption already succeeded above) — transient,
|
||||
# retry the batch.
|
||||
logger.warning("Web Push failed for channel %s: %s", channel.id, exc)
|
||||
transient += 1
|
||||
continue
|
||||
|
||||
if resp.status_code in (200, 201, 202):
|
||||
delivered += 1
|
||||
continue
|
||||
if resp.status_code in (404, 410):
|
||||
logger.info("Web Push reports channel %s gone", channel.id)
|
||||
stale.append(channel)
|
||||
elif _is_transient_status(resp.status_code):
|
||||
logger.warning(
|
||||
"Web Push transient failure for channel %s: status=%s",
|
||||
channel.id,
|
||||
resp.status_code,
|
||||
)
|
||||
transient += 1
|
||||
else:
|
||||
logger.warning(
|
||||
"Web Push rejected channel %s: status=%s",
|
||||
channel.id,
|
||||
resp.status_code,
|
||||
)
|
||||
|
||||
_deactivate_stale_channels(stale, len(items), platform="web")
|
||||
return PushResult(delivered, transient)
|
||||
@@ -4,6 +4,7 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_delete, post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
@@ -14,6 +15,7 @@ from core.services.identity.keycloak import (
|
||||
sync_mailbox_to_keycloak_user,
|
||||
sync_maildomain_to_keycloak_group,
|
||||
)
|
||||
from core.services.push.common import session_hash
|
||||
from core.services.search.coalescer import (
|
||||
enqueue_message_delete,
|
||||
enqueue_thread_delete,
|
||||
@@ -259,3 +261,37 @@ def delete_user_scope_channels_on_user_delete(sender, instance, **kwargs):
|
||||
user=instance,
|
||||
scope_level=enums.ChannelScopeLevel.USER,
|
||||
).delete()
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def unregister_push_device_on_logout(sender, request, user, **kwargs):
|
||||
"""Unregister the push channel(s) bound to the session being logged out.
|
||||
|
||||
This is what makes a *voluntary* logout an opt-out of notifications for
|
||||
that device, server-side (works even if the browser JS never runs — e.g.
|
||||
logout hit directly). It fires before ``auth.logout`` flushes the session,
|
||||
so the session key is still readable. Only channels stamped with THIS
|
||||
session are removed — the same user's other devices keep receiving.
|
||||
|
||||
A session that merely *expires* never reaches ``auth.logout`` with an
|
||||
authenticated user (the 401 funnel arrives anonymous), so this receiver
|
||||
no-ops and the device keeps receiving — the product rule: notifications
|
||||
survive expiry, stop on explicit logout, and resume transparently when the
|
||||
client re-registers on the next login.
|
||||
"""
|
||||
if user is None or request is None:
|
||||
return
|
||||
session_key = getattr(getattr(request, "session", None), "session_key", None)
|
||||
if not session_key:
|
||||
return
|
||||
try:
|
||||
models.Channel.objects.filter(
|
||||
user=user,
|
||||
type=enums.ChannelTypes.PUSH,
|
||||
settings__session_hash=session_hash(session_key),
|
||||
).delete()
|
||||
# pylint: disable=broad-exception-caught
|
||||
except Exception:
|
||||
# Never break the logout flow itself; an undeleted channel is only a
|
||||
# notification-hygiene issue and the next voluntary logout retries.
|
||||
logger.exception("Failed to unregister push channels on logout")
|
||||
|
||||
@@ -10,5 +10,6 @@ from core.services.blob_gc import * # noqa: F403
|
||||
from core.services.calendar.tasks import * # noqa: F403
|
||||
from core.services.dns.tasks import * # noqa: F403
|
||||
from core.services.importer.tasks import * # noqa: F403
|
||||
from core.services.push.tasks import * # noqa: F403
|
||||
from core.services.search.tasks import * # noqa: F403
|
||||
from core.services.tiered_storage_tasks import * # noqa: F403
|
||||
|
||||
@@ -38,6 +38,10 @@ pytestmark = pytest.mark.django_db
|
||||
MESSAGES_MANUAL_RETRY_MAX_AGE=86400, # 1 day in seconds
|
||||
FRONTEND_SILENT_LOGIN_ENABLED=True,
|
||||
RELEASE="1.2.3",
|
||||
PUSH_ENABLED=False,
|
||||
PUSH_VAPID_PRIVATE_KEY=None,
|
||||
PUSH_VAPID_PUBLIC_KEY=None,
|
||||
FRONTEND_THEME_CONFIG=None,
|
||||
)
|
||||
@pytest.mark.parametrize("is_authenticated", [False, True])
|
||||
def test_api_config(is_authenticated):
|
||||
@@ -72,6 +76,7 @@ def test_api_config(is_authenticated):
|
||||
"MESSAGE_TRUSTED_LINK_DOMAINS": [],
|
||||
"MESSAGES_MANUAL_RETRY_MAX_AGE": 86400,
|
||||
"FRONTEND_SILENT_LOGIN_ENABLED": True,
|
||||
"PUSH_ENABLED": False,
|
||||
}
|
||||
# Optional settings left unconfigured must be omitted, not null nor
|
||||
# defaulted: the frontend falls back on its deprecated NEXT_PUBLIC_*
|
||||
@@ -86,6 +91,8 @@ def test_api_config(is_authenticated):
|
||||
assert "FRONTEND_FEEDBACK_WIDGET_CONFIG" not in response.json()
|
||||
assert "FRONTEND_LAGAUFRE_WIDGET_CONFIG" not in response.json()
|
||||
assert "MOBILE_OTA_MANIFEST_URL" not in response.json()
|
||||
assert "PUSH_VAPID_PUBLIC_KEY" not in response.json()
|
||||
assert "PUSH_VAPID_PRIVATE_KEY" not in response.json()
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -160,7 +167,10 @@ def test_api_config_trusted_link_domains():
|
||||
"""The trusted-link-domains allowlist should be exposed to the frontend."""
|
||||
response = APIClient().get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["MESSAGE_TRUSTED_LINK_DOMAINS"] == ["gouv.fr", "*.example.com"]
|
||||
assert response.json()["MESSAGE_TRUSTED_LINK_DOMAINS"] == [
|
||||
"gouv.fr",
|
||||
"*.example.com",
|
||||
]
|
||||
|
||||
|
||||
@override_settings(
|
||||
|
||||
@@ -3682,6 +3682,42 @@ class TestPipelineIdempotency:
|
||||
assert mock_delay.call_count == 1
|
||||
assert models.Message.objects.filter(mime_id=mime).count() == 1
|
||||
|
||||
@override_settings(PUSH_ENABLED=True)
|
||||
@patch("core.mda.inbound_tasks.enqueue_push_notifications")
|
||||
@patch("core.mda.spam.call_rspamd")
|
||||
def test_dedup_hit_does_not_refire_push(self, mock_rspamd, mock_enqueue_push):
|
||||
"""A duplicate delivery must not re-enqueue push notifications — the
|
||||
push dispatch is a finalize side effect gated on ``_created_now``.
|
||||
``enqueue_push_notifications`` has no idempotency of its own (the
|
||||
collapse key only coalesces in the tray, ``renotify`` re-alerts), so a
|
||||
second enqueue on an SMTP redelivery would wake the device again for a
|
||||
message it already announced."""
|
||||
mailbox = factories.MailboxFactory()
|
||||
mock_rspamd.return_value = ("no action", None, None)
|
||||
|
||||
mime = "idem-push@example.com"
|
||||
raw_data = (
|
||||
b"From: customer@example.com\r\n"
|
||||
b"To: " + str(mailbox).encode() + b"\r\n"
|
||||
b"Subject: help\r\n"
|
||||
b"Message-ID: <" + mime.encode() + b">\r\n\r\nbody"
|
||||
)
|
||||
|
||||
im1 = _queue_inbound(mailbox, raw_data)
|
||||
with patch.object(process_inbound_message_task, "update_state", Mock()):
|
||||
process_inbound_message_task.run(str(im1.id))
|
||||
# Enqueued exactly once, on the original create.
|
||||
assert mock_enqueue_push.call_count == 1
|
||||
|
||||
# Duplicate delivery: same Message-ID, separate queue row.
|
||||
im2 = _queue_inbound(mailbox, raw_data)
|
||||
with patch.object(process_inbound_message_task, "update_state", Mock()):
|
||||
process_inbound_message_task.run(str(im2.id))
|
||||
|
||||
# Still 1 — the dedup hit skipped the push enqueue.
|
||||
assert mock_enqueue_push.call_count == 1
|
||||
assert models.Message.objects.filter(mime_id=mime).count() == 1
|
||||
|
||||
|
||||
# --- cross-retry blocking-webhook result cache --- #
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,3 +51,149 @@ def test_invalid_settings_oidc_refresh_token_configuration():
|
||||
"OIDC_STORE_REFRESH_TOKEN_KEY must be set when "
|
||||
"OIDC_STORE_REFRESH_TOKEN is enabled."
|
||||
)
|
||||
|
||||
|
||||
def test_web_push_public_key_without_private_key_is_rejected():
|
||||
"""A VAPID public key set without the private key must fail at boot.
|
||||
|
||||
/config would advertise the public key and enrol browsers, yet every send
|
||||
would silently no-op (the web sender needs the private key to sign). The
|
||||
validation is symmetric, so this half-configuration is caught like the
|
||||
reverse one.
|
||||
"""
|
||||
|
||||
class TestSettings(Base):
|
||||
"""Fake test settings."""
|
||||
|
||||
PUSH_ENABLED = True
|
||||
PUSH_VAPID_PRIVATE_KEY = None
|
||||
PUSH_VAPID_PUBLIC_KEY = "public-key"
|
||||
PUSH_VAPID_SUBJECT = "mailto:ops@example.com"
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
TestSettings().post_setup()
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "partially configured" in message
|
||||
assert "PUSH_VAPID_PRIVATE_KEY missing" in message
|
||||
|
||||
|
||||
def test_web_push_private_key_without_public_key_is_rejected():
|
||||
"""A VAPID private key set without the public key must fail at boot.
|
||||
|
||||
The browser needs the public key as its applicationServerKey to subscribe;
|
||||
without it, nobody can enrol.
|
||||
"""
|
||||
|
||||
class TestSettings(Base):
|
||||
"""Fake test settings."""
|
||||
|
||||
PUSH_ENABLED = True
|
||||
PUSH_VAPID_PRIVATE_KEY = "private-key"
|
||||
PUSH_VAPID_PUBLIC_KEY = None
|
||||
PUSH_VAPID_SUBJECT = "mailto:ops@example.com"
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
TestSettings().post_setup()
|
||||
|
||||
assert "PUSH_VAPID_PUBLIC_KEY missing" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_web_push_native_only_deployment_needs_no_vapid():
|
||||
"""PUSH_ENABLED with no VAPID value at all is valid (native-only APNs/FCM).
|
||||
|
||||
PUSH_ENABLED alone must never force VAPID onto an instance that only ships
|
||||
native push.
|
||||
"""
|
||||
|
||||
class TestSettings(Base):
|
||||
"""Fake test settings."""
|
||||
|
||||
PUSH_ENABLED = True
|
||||
PUSH_VAPID_PRIVATE_KEY = None
|
||||
PUSH_VAPID_PUBLIC_KEY = None
|
||||
PUSH_VAPID_SUBJECT = None
|
||||
|
||||
# Does not raise.
|
||||
TestSettings().post_setup()
|
||||
|
||||
|
||||
def test_apns_partial_configuration_is_rejected():
|
||||
"""An incomplete APNs group must fail at boot.
|
||||
|
||||
The iOS sender gates on all four values (apns_configured), so a partial
|
||||
group silently drops every send even with PUSH_ENABLED True.
|
||||
"""
|
||||
|
||||
class TestSettings(Base):
|
||||
"""Fake test settings."""
|
||||
|
||||
PUSH_ENABLED = True
|
||||
PUSH_APNS_KEY = "apns-key"
|
||||
PUSH_APNS_KEY_ID = "key-id"
|
||||
PUSH_APNS_TEAM_ID = None
|
||||
PUSH_APNS_BUNDLE_ID = None
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
TestSettings().post_setup()
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "APNs is partially configured" in message
|
||||
assert "PUSH_APNS_TEAM_ID" in message
|
||||
assert "PUSH_APNS_BUNDLE_ID" in message
|
||||
|
||||
|
||||
def test_fcm_partial_configuration_is_rejected():
|
||||
"""FCM credentials without the project id must fail at boot.
|
||||
|
||||
The Android sender gates on both values (fcm_configured), so a partial
|
||||
group silently drops every send even with PUSH_ENABLED True.
|
||||
"""
|
||||
|
||||
class TestSettings(Base):
|
||||
"""Fake test settings."""
|
||||
|
||||
PUSH_ENABLED = True
|
||||
PUSH_FCM_CREDENTIALS = '{"type": "service_account"}'
|
||||
PUSH_FCM_PROJECT_ID = None
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
TestSettings().post_setup()
|
||||
|
||||
assert "FCM is partially configured" in str(excinfo.value)
|
||||
assert "PUSH_FCM_PROJECT_ID missing" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_push_fully_configured_gateways_pass_validation():
|
||||
"""Complete groups (any subset of gateways) boot fine with PUSH_ENABLED."""
|
||||
|
||||
class TestSettings(Base):
|
||||
"""Fake test settings."""
|
||||
|
||||
PUSH_ENABLED = True
|
||||
PUSH_APNS_KEY = "apns-key"
|
||||
PUSH_APNS_KEY_ID = "key-id"
|
||||
PUSH_APNS_TEAM_ID = "team-id"
|
||||
PUSH_APNS_BUNDLE_ID = "com.example.app"
|
||||
PUSH_FCM_CREDENTIALS = '{"type": "service_account"}'
|
||||
PUSH_FCM_PROJECT_ID = "example-project"
|
||||
|
||||
# Does not raise: APNs and FCM are complete, VAPID is fully unset.
|
||||
TestSettings().post_setup()
|
||||
|
||||
|
||||
def test_web_push_subject_must_be_mailto_or_https():
|
||||
"""A fully configured VAPID trio with a bare-email subject is rejected."""
|
||||
|
||||
class TestSettings(Base):
|
||||
"""Fake test settings."""
|
||||
|
||||
PUSH_ENABLED = True
|
||||
PUSH_VAPID_PRIVATE_KEY = "private-key"
|
||||
PUSH_VAPID_PUBLIC_KEY = "public-key"
|
||||
PUSH_VAPID_SUBJECT = "ops@example.com" # missing mailto:
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
TestSettings().post_setup()
|
||||
|
||||
assert "RFC 8292" in str(excinfo.value)
|
||||
|
||||
@@ -236,6 +236,112 @@ class Base(Configuration):
|
||||
[], environ_name="MESSAGE_TRUSTED_LINK_DOMAINS", environ_prefix=None
|
||||
)
|
||||
|
||||
# Push notifications (mobile/web)
|
||||
#
|
||||
# Master switch. Everything in core.services.push is a hard no-op while
|
||||
# this is False, so the feature is safe to merge dark: no tokens are
|
||||
# pushed to, no external gateway is contacted, and the enqueue helper
|
||||
# never schedules the Celery task. Flip to True *and* configure at least
|
||||
# one gateway below to go live.
|
||||
PUSH_ENABLED = values.BooleanValue(
|
||||
False, environ_name="PUSH_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
# APNs (Apple Push Notification service) — token-based auth (.p8).
|
||||
# All four must be set for the iOS sender to be live. With PUSH_ENABLED
|
||||
# True, post_setup rejects a partially set group at boot; leaving the
|
||||
# whole group unset simply keeps the iOS sender off.
|
||||
PUSH_APNS_KEY = values.Value(
|
||||
None,
|
||||
environ_name="PUSH_APNS_KEY",
|
||||
environ_prefix=None,
|
||||
help_text="Contents of the APNs auth key .p8 file (PEM).",
|
||||
)
|
||||
PUSH_APNS_KEY_ID = values.Value(
|
||||
None, environ_name="PUSH_APNS_KEY_ID", environ_prefix=None
|
||||
)
|
||||
PUSH_APNS_TEAM_ID = values.Value(
|
||||
None, environ_name="PUSH_APNS_TEAM_ID", environ_prefix=None
|
||||
)
|
||||
PUSH_APNS_BUNDLE_ID = values.Value(
|
||||
None,
|
||||
environ_name="PUSH_APNS_BUNDLE_ID",
|
||||
environ_prefix=None,
|
||||
help_text="App bundle id, used as the APNs topic.",
|
||||
)
|
||||
# Default (False) uses Apple's production gateway. Set True for the sandbox
|
||||
# gateway, which only accepts tokens from a development-signed build.
|
||||
PUSH_APNS_USE_SANDBOX = values.BooleanValue(
|
||||
False, environ_name="PUSH_APNS_USE_SANDBOX", environ_prefix=None
|
||||
)
|
||||
|
||||
# FCM (Firebase Cloud Messaging) HTTP v1 — service-account credentials.
|
||||
# The service-account JSON yields an OAuth token; the project id selects
|
||||
# the v1 endpoint. Both must be set for the Android sender to be live
|
||||
# (post_setup rejects setting only one of them at boot).
|
||||
# No sandbox switch (unlike APNs): FCM has a single endpoint — separate
|
||||
# staging from production by pointing at a different Firebase project
|
||||
# (its own credentials + project id), not a flag.
|
||||
PUSH_FCM_CREDENTIALS = values.Value(
|
||||
None,
|
||||
environ_name="PUSH_FCM_CREDENTIALS",
|
||||
environ_prefix=None,
|
||||
help_text="Service-account JSON (the whole file contents) as a string.",
|
||||
)
|
||||
PUSH_FCM_PROJECT_ID = values.Value(
|
||||
None, environ_name="PUSH_FCM_PROJECT_ID", environ_prefix=None
|
||||
)
|
||||
|
||||
# Web Push (VAPID). The private key + subject identify this server to the
|
||||
# browser push services. Both must be set for the web sender to be live.
|
||||
# No sandbox switch (unlike APNs): Web Push has no test gateway — delivery
|
||||
# goes to whatever push-service URL the browser put in the subscription, and
|
||||
# these keys are environment-agnostic (the same pair works everywhere).
|
||||
#
|
||||
# OPERATOR NOTE — keep the public/private pair in sync: the browser subscribes
|
||||
# with PUSH_VAPID_PUBLIC_KEY as its applicationServerKey, and the push service
|
||||
# then verifies every notification against the JWT this PRIVATE key signs. If
|
||||
# the two don't match, *all* web push silently fails VAPID verification (403)
|
||||
# — no error at registration or in /config. The public key is deterministic
|
||||
# from this private key: derive the correct value with
|
||||
# ``python manage.py derive_vapid_public_key`` and pin it as
|
||||
# PUSH_VAPID_PUBLIC_KEY. After rotating this private key, re-derive and update
|
||||
# the public key (and note that rotating it orphans every existing web
|
||||
# subscription — clients must re-subscribe). Run
|
||||
# ``derive_vapid_public_key --verify`` to check the configured pair matches.
|
||||
PUSH_VAPID_PRIVATE_KEY = values.Value(
|
||||
None,
|
||||
environ_name="PUSH_VAPID_PRIVATE_KEY",
|
||||
environ_prefix=None,
|
||||
help_text="VAPID application-server private key (PEM or base64url).",
|
||||
)
|
||||
# The matching PUBLIC key, base64url-encoded (the uncompressed P-256 point
|
||||
# the browser passes as ``applicationServerKey``). Exposed via /config so the
|
||||
# web client can subscribe — it is public by definition, safe to publish.
|
||||
# Required for Web Push: /config serves it verbatim and never derives it (so
|
||||
# the web worker need not import the push graph). Obtain it once from the
|
||||
# private key with the ``derive_vapid_public_key`` management command.
|
||||
PUSH_VAPID_PUBLIC_KEY = values.Value(
|
||||
None,
|
||||
environ_name="PUSH_VAPID_PUBLIC_KEY",
|
||||
environ_prefix=None,
|
||||
help_text="VAPID application-server public key (base64url). Pair of the private key.",
|
||||
)
|
||||
PUSH_VAPID_SUBJECT = values.Value(
|
||||
None,
|
||||
environ_name="PUSH_VAPID_SUBJECT",
|
||||
environ_prefix=None,
|
||||
help_text="VAPID `sub` claim, e.g. 'mailto:ops@example.com'.",
|
||||
)
|
||||
|
||||
# Hard ceiling on how many push devices one user may keep. Registering a new
|
||||
# device beyond this prunes the user's least-recently-used device(s). Backs
|
||||
# the (deliberately loose) device_registration throttle: the throttle caps
|
||||
# request *rate*, this caps the persistent *fleet* a single account can grow.
|
||||
PUSH_MAX_DEVICES_PER_USER = values.IntegerValue(
|
||||
20, environ_name="PUSH_MAX_DEVICES_PER_USER", environ_prefix=None
|
||||
)
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = values.Value(None)
|
||||
@@ -925,6 +1031,16 @@ class Base(Configuration):
|
||||
environ_name="API_MOBILE_AUTH_EXCHANGE_THROTTLE_RATE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
# Per-user cap on push device (re)registration. Clients re-register
|
||||
# on every cold launch (and on token rotation) to refresh the
|
||||
# device and its last_used_at, so this must comfortably exceed
|
||||
# normal relaunch frequency while still bounding abuse; the hard
|
||||
# ceiling on distinct devices is PUSH_MAX_DEVICES_PER_USER, not this.
|
||||
"device_registration": values.Value(
|
||||
default="30/hour",
|
||||
environ_name="API_DEVICE_REGISTRATION_THROTTLE_RATE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1504,6 +1620,67 @@ class Base(Configuration):
|
||||
"OIDC_STORE_REFRESH_TOKEN is enabled."
|
||||
)
|
||||
|
||||
# Push gateway config consistency. Each gateway's settings are a
|
||||
# unit: its sender only goes live once the full group is set
|
||||
# (webpush_configured, apns_configured, fcm_configured) and
|
||||
# no-ops (with a warning) otherwise. A *partial* group means the operator
|
||||
# intended that gateway, yet every send would be dropped with no
|
||||
# error (delivered=0) — VAPID being the sharpest case, where a public
|
||||
# key without the private one is still advertised by /config and
|
||||
# enrols browsers that can never be reached. So with the feature
|
||||
# live, each group must be fully set or fully unset. An absent group
|
||||
# stays valid: deployments configure only the gateways they ship,
|
||||
# PUSH_ENABLED alone forces none of them.
|
||||
if cls.PUSH_ENABLED:
|
||||
push_gateways = {
|
||||
"Web Push (VAPID)": (
|
||||
{
|
||||
"PUSH_VAPID_PRIVATE_KEY": cls.PUSH_VAPID_PRIVATE_KEY,
|
||||
"PUSH_VAPID_PUBLIC_KEY": cls.PUSH_VAPID_PUBLIC_KEY,
|
||||
"PUSH_VAPID_SUBJECT": cls.PUSH_VAPID_SUBJECT,
|
||||
},
|
||||
" Derive the public key with "
|
||||
"`python manage.py derive_vapid_public_key`.",
|
||||
),
|
||||
"APNs": (
|
||||
{
|
||||
"PUSH_APNS_KEY": cls.PUSH_APNS_KEY,
|
||||
"PUSH_APNS_KEY_ID": cls.PUSH_APNS_KEY_ID,
|
||||
"PUSH_APNS_TEAM_ID": cls.PUSH_APNS_TEAM_ID,
|
||||
"PUSH_APNS_BUNDLE_ID": cls.PUSH_APNS_BUNDLE_ID,
|
||||
},
|
||||
"",
|
||||
),
|
||||
"FCM": (
|
||||
{
|
||||
"PUSH_FCM_CREDENTIALS": cls.PUSH_FCM_CREDENTIALS,
|
||||
"PUSH_FCM_PROJECT_ID": cls.PUSH_FCM_PROJECT_ID,
|
||||
},
|
||||
"",
|
||||
),
|
||||
}
|
||||
for gateway, (group, hint) in push_gateways.items():
|
||||
missing = [name for name, value in group.items() if not value] # pylint: disable=no-member
|
||||
if missing and len(missing) < len(group):
|
||||
raise ValueError(
|
||||
f"{gateway} is partially configured: "
|
||||
f"{', '.join(missing)} missing. Set all of "
|
||||
f"{', '.join(group)} together, or none.{hint}"
|
||||
)
|
||||
# Scheme check only (RFC 8292). We can't validate here that the
|
||||
# contact is routable — a `.local`/`localhost` domain passes this
|
||||
# yet is rejected at runtime by Apple (Safari) with a 403; see
|
||||
# webpush._valid_vapid_subject. Inlined rather than imported so
|
||||
# settings load never pulls in the push/crypto graph.
|
||||
subject = cls.PUSH_VAPID_SUBJECT
|
||||
if subject and not (
|
||||
subject.startswith("mailto:") or subject.startswith("https://") # pylint: disable=no-member
|
||||
):
|
||||
raise ValueError(
|
||||
"PUSH_VAPID_SUBJECT must be a 'mailto:' or 'https:' URI "
|
||||
f"(RFC 8292); got {subject!r}."
|
||||
)
|
||||
|
||||
|
||||
class Build(Base):
|
||||
"""Settings used when the application is built.
|
||||
|
||||
@@ -47,6 +47,8 @@ dependencies = [
|
||||
"dkimpy==1.1.8",
|
||||
"dnspython==2.8.0",
|
||||
"drf_spectacular==0.29.0",
|
||||
"google-auth==2.41.1",
|
||||
"httpx[http2]==0.28.1",
|
||||
"opensearch-py==2.8.0",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==25.1.0",
|
||||
@@ -68,6 +70,8 @@ dependencies = [
|
||||
"url-normalize==2.2.1",
|
||||
"whitenoise==6.11.0",
|
||||
"prometheus-client==0.24.1",
|
||||
"py-vapid==1.9.4",
|
||||
"http-ece==1.2.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
Generated
+121
@@ -144,6 +144,15 @@ filecache = [
|
||||
{ name = "filelock" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "6.2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "celery"
|
||||
version = "5.6.2"
|
||||
@@ -782,6 +791,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/ff/ee2f67c0ff146ec98b5df1df637b2bc2d17beeb05df9f427a67bd7a7d79c/flower-2.0.1-py2.py3-none-any.whl", hash = "sha256:9db2c621eeefbc844c8dd88be64aef61e84e2deb29b271e02ab2b5b9f01068e2", size = 383553, upload-time = "2023-08-13T14:37:41.552Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth"
|
||||
version = "2.41.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cachetools" },
|
||||
{ name = "pyasn1-modules" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/af/5129ce5b2f9688d2fa49b463e544972a7c82b0fdb50980dafee92e121d9f/google_auth-2.41.1.tar.gz", hash = "sha256:b76b7b1f9e61f0cb7e88870d14f6a94aeef248959ef6992670efee37709cbfd2", size = 292284, upload-time = "2025-09-30T22:51:26.363Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl", hash = "sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d", size = 221302, upload-time = "2025-09-30T22:51:24.212Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "25.1.0"
|
||||
@@ -803,6 +826,37 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "hpack" },
|
||||
{ name = "hyperframe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hpack"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-ece"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/af/249d1576653b69c20b9ac30e284b63bd94af6a175d72d87813235caf2482/http_ece-1.2.1.tar.gz", hash = "sha256:8c6ab23116bbf6affda894acfd5f2ca0fb8facbcbb72121c11c75c33e7ce8cff", size = 8830, upload-time = "2024-08-08T00:10:47.301Z" }
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
@@ -831,6 +885,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
http2 = [
|
||||
{ name = "h2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humanize"
|
||||
version = "4.15.0"
|
||||
@@ -840,6 +899,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyperframe"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hypothesis"
|
||||
version = "6.151.9"
|
||||
@@ -1119,7 +1187,10 @@ dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "drf-spectacular" },
|
||||
{ name = "factory-boy" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "http-ece" },
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "icalendar" },
|
||||
{ name = "jmap-email" },
|
||||
{ name = "jsonschema" },
|
||||
@@ -1129,6 +1200,7 @@ dependencies = [
|
||||
{ name = "opensearch-py" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "py-vapid" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pysocks" },
|
||||
{ name = "python-keycloak" },
|
||||
@@ -1191,7 +1263,10 @@ requires-dist = [
|
||||
{ name = "drf-spectacular-sidecar", marker = "extra == 'dev'", specifier = "==2026.1.1" },
|
||||
{ name = "factory-boy", specifier = "==3.3.3" },
|
||||
{ name = "flower", marker = "extra == 'dev'", specifier = "==2.0.1" },
|
||||
{ name = "google-auth", specifier = "==2.41.1" },
|
||||
{ name = "gunicorn", specifier = "==25.1.0" },
|
||||
{ name = "http-ece", specifier = "==1.2.1" },
|
||||
{ name = "httpx", extras = ["http2"], specifier = "==0.28.1" },
|
||||
{ name = "hypothesis", marker = "extra == 'dev'", specifier = "==6.151.9" },
|
||||
{ name = "icalendar", specifier = "==7.0.3" },
|
||||
{ name = "jmap-email", specifier = "==0.1.0" },
|
||||
@@ -1204,6 +1279,7 @@ requires-dist = [
|
||||
{ name = "pipdeptree", marker = "extra == 'dev'", specifier = "==2.31.0" },
|
||||
{ name = "prometheus-client", specifier = "==0.24.1" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.3" },
|
||||
{ name = "py-vapid", specifier = "==1.9.4" },
|
||||
{ name = "pyjwt", specifier = "==2.13.0" },
|
||||
{ name = "pylint", marker = "extra == 'dev'", specifier = "==4.0.4" },
|
||||
{ name = "pylint-django", marker = "extra == 'dev'", specifier = "==2.7.0" },
|
||||
@@ -1504,6 +1580,39 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-vapid"
|
||||
version = "1.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/ed/c648c8018fab319951764f4babe68ddcbbff7f2bbcd7ff7e531eac1788c8/py_vapid-1.9.4.tar.gz", hash = "sha256:a004023560cbc54e34fc06380a0580f04ffcc788e84fb6d19e9339eeb6551a28", size = 74750, upload-time = "2026-01-05T22:13:25.201Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/15/f9d0171e1ad863ca49e826d5afb6b50566f20dc9b4f76965096d3555ce9e/py_vapid-1.9.4-py2.py3-none-any.whl", hash = "sha256:f165a5bf90dcf966b226114f01f178f137579a09784c7f0628fa2f0a299741b6", size = 23912, upload-time = "2026-01-05T20:42:05.455Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
@@ -1948,6 +2057,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.2"
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
# Per-instance Firebase config (FCM push): like MOBILE_APP_ID, the publishing
|
||||
# organisation supplies its own — never commit one to this open-source repo.
|
||||
google-services.json
|
||||
|
||||
@@ -11,7 +11,9 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-device')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capgo-capacitor-updater')
|
||||
|
||||
|
||||
@@ -33,6 +33,21 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
FCM rendering defaults for pushes arriving while the app is killed
|
||||
(the SDK builds the notification itself then). The channel id must
|
||||
match ANDROID_NOTIFICATION_CHANNEL_ID (features/native/push.ts,
|
||||
where the channel is created) and FCM_ANDROID_CHANNEL_ID
|
||||
(core/services/push/fcm.py); the icon is the monochrome status
|
||||
silhouette (colored launcher icons render as a flat disc there).
|
||||
-->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="new_messages" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_stat_notification" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -45,4 +60,9 @@
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!--
|
||||
Android 13+ runtime permission behind the notification prompt driven by
|
||||
PushNotifications.requestPermissions() (features/native/push.ts).
|
||||
-->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
</manifest>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<!-- Only the push banner strings are localized; everything else (app name,
|
||||
scheme...) is locale-independent and falls back to values/strings.xml. -->
|
||||
<resources>
|
||||
<string name="new_message_title">Messages</string>
|
||||
<string name="new_message_body">Nouveau message</string>
|
||||
</resources>
|
||||
@@ -4,4 +4,12 @@
|
||||
<string name="title_activity_main">Messages</string>
|
||||
<string name="package_name">local.suitenumerique.messages</string>
|
||||
<string name="custom_url_scheme">local.suitenumerique.messages</string>
|
||||
|
||||
<!-- Content-free push banner: the FCM message carries only these
|
||||
localization keys (core/services/push/fcm.py, FCM_TITLE_LOC_KEY /
|
||||
FCM_BODY_LOC_KEY) — never sender or subject — and the OS renders the
|
||||
strings from here even when the app is killed. Keep in sync with the
|
||||
iOS Localizable.strings NEW_MESSAGE entry. -->
|
||||
<string name="new_message_title">Messages</string>
|
||||
<string name="new_message_body">New message</string>
|
||||
</resources>
|
||||
|
||||
@@ -8,9 +8,15 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
|
||||
include ':capacitor-browser'
|
||||
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||
|
||||
include ':capacitor-device'
|
||||
project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
|
||||
@@ -13,4 +13,7 @@ ext {
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
// Pin the transport used by @capacitor/push-notifications (matches the
|
||||
// plugin's own default) instead of floating on it.
|
||||
firebaseMessagingVersion = '25.0.1'
|
||||
}
|
||||
@@ -23,7 +23,7 @@ if (process.env.NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL && !otaPublicKey) {
|
||||
"NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL is set but " +
|
||||
"MOBILE_OTA_SIGNING_PUBLIC_KEY_B64 is missing: an OTA-enabled build " +
|
||||
"must embed the signing public key (run `make mobile-ota-keygen`, see " +
|
||||
"env.d/development/frontend.defaults).",
|
||||
"deploy/env/frontend.defaults).",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ const otaBuildId = process.env.MOBILE_OTA_BUILD_ID;
|
||||
// Dev-only hot reload: when set, the WebView loads the app straight from the
|
||||
// Vite dev server instead of the embedded dist/, so JS/CSS changes apply
|
||||
// through HMR without rebuilding or reinstalling the app. Set by default in
|
||||
// env.d/development/frontend.defaults (http://localhost:8900, reachable from
|
||||
// deploy/env/frontend.defaults (http://localhost:8900, reachable from
|
||||
// the device via adb reverse on Android / the simulator loopback on iOS);
|
||||
// disable it with an empty value in frontend.local. Must NEVER be set for a
|
||||
// release build — the URL is baked into the shipped config (a gradle guard
|
||||
@@ -85,6 +85,15 @@ const config: CapacitorConfig = {
|
||||
SystemBars: {
|
||||
insetsHandling: "disable",
|
||||
},
|
||||
// While the app is open it surfaces the mail itself, so a foreground push
|
||||
// must not banner or sound (docs/push-notifications.md §6) — only the badge
|
||||
// tracks. iOS only: Android never auto-displays in foreground, and the web
|
||||
// service worker applies the same rule on a focused window (public/sw.js).
|
||||
// Background/killed alerts are rendered by the OS from the content-free
|
||||
// loc-key payload and are unaffected.
|
||||
PushNotifications: {
|
||||
presentationOptions: ["badge"],
|
||||
},
|
||||
// OTA live updates driven entirely from JS against an S3-hosted manifest
|
||||
// (see src/features/native/ota.ts). autoUpdate is off so the plugin never
|
||||
// talks to a Capgo server: we only use its native download/set/reload.
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
7A11AA0100000000000000A2 /* WebAuthSessionPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AA0100000000000000A1 /* WebAuthSessionPlugin.swift */; };
|
||||
7A11AA0100000000000000B2 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AA0100000000000000B1 /* MainViewController.swift */; };
|
||||
7A11AA0100000000000000E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7A11AA0100000000000000E0 /* Localizable.strings */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -31,6 +32,9 @@
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
7A11AA0100000000000000A1 /* WebAuthSessionPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthSessionPlugin.swift; sourceTree = "<group>"; };
|
||||
7A11AA0100000000000000B1 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
|
||||
7A11AA0100000000000000D1 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
7A11AA0100000000000000E1 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
7A11AA0100000000000000E2 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -66,6 +70,7 @@
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7A11AA0100000000000000D1 /* App.entitlements */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
7A11AA0100000000000000B1 /* MainViewController.swift */,
|
||||
@@ -73,6 +78,7 @@
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
7A11AA0100000000000000E0 /* Localizable.strings */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
@@ -126,6 +132,7 @@
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
fr,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
@@ -151,6 +158,7 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
7A11AA0100000000000000E3 /* Localizable.strings in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -195,6 +203,15 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
7A11AA0100000000000000E0 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
7A11AA0100000000000000E1 /* en */,
|
||||
7A11AA0100000000000000E2 /* fr */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
@@ -328,6 +345,7 @@
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
@@ -351,6 +369,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
Push Notifications capability (docs/push-notifications.md §3). The value
|
||||
here is what development-signed builds use — it must match the backend's
|
||||
PUSH_APNS_USE_SANDBOX=True (sandbox gateway). Xcode's archive export
|
||||
rewrites it to "production" when re-signing for distribution, which pairs
|
||||
with PUSH_APNS_USE_SANDBOX=False. The App ID must have the Push
|
||||
Notifications capability enabled in the Apple developer portal.
|
||||
-->
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,5 +1,6 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import UserNotifications
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
@@ -49,7 +50,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
// The push badge carries the unread count as of send time; once the
|
||||
// user is in the app it is stale, so drop it (web counterpart:
|
||||
// clearAppBadge in features/auth). Delivered banners are dismissed by
|
||||
// the JS side (clearDeliveredNativeNotifications).
|
||||
if #available(iOS 16.0, *) {
|
||||
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||
} else {
|
||||
application.applicationIconBadgeNumber = 0
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
@@ -69,4 +78,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
// Hand the APNs registration outcome to @capacitor/push-notifications:
|
||||
// its native side observes these notifications and resolves the JS
|
||||
// "registration"/"registrationError" events the app awaits
|
||||
// (features/native/push.ts, obtainToken).
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -52,6 +52,15 @@
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<!--
|
||||
Wakes the app for remote notifications so the (future) fetch-to-enrich /
|
||||
background refresh path can run; the visible loc-key alert itself is
|
||||
rendered by the OS and does not need it.
|
||||
-->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Content-free push banner: the APNs alert carries only this localization key
|
||||
(core/services/push/apns.py, APNS_ALERT_LOC_KEY) — never sender or subject —
|
||||
and the OS renders the string from here even when the app is killed. Keep in
|
||||
sync with the Android strings.xml new_message_* entries and the Web Push
|
||||
service worker fallback banner. */
|
||||
"NEW_MESSAGE" = "New message";
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Bannière push sans contenu : l'alerte APNs ne transporte que cette clé de
|
||||
localisation (core/services/push/apns.py, APNS_ALERT_LOC_KEY) — jamais
|
||||
l'expéditeur ni le sujet — et l'OS affiche la chaîne ci-dessous même quand
|
||||
l'app est tuée. À garder aligné avec les entrées new_message_* du
|
||||
strings.xml Android et la bannière de repli du service worker Web Push. */
|
||||
"NEW_MESSAGE" = "Nouveau message";
|
||||
@@ -14,7 +14,9 @@ let package = Package(
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.0"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorBrowser", path: "../../../node_modules/@capacitor/browser"),
|
||||
.package(name: "CapacitorDevice", path: "../../../node_modules/@capacitor/device"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorPushNotifications", path: "../../../node_modules/@capacitor/push-notifications"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
.package(name: "CapgoCapacitorUpdater", path: "../../../node_modules/@capgo/capacitor-updater")
|
||||
],
|
||||
@@ -26,7 +28,9 @@ let package = Package(
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorBrowser", package: "CapacitorBrowser"),
|
||||
.product(name: "CapacitorDevice", package: "CapacitorDevice"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"),
|
||||
.product(name: "CapacitorShare", package: "CapacitorShare"),
|
||||
.product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater")
|
||||
]
|
||||
|
||||
Generated
+20
@@ -14,7 +14,9 @@
|
||||
"@capacitor/app": "8.1.0",
|
||||
"@capacitor/browser": "8.0.3",
|
||||
"@capacitor/core": "8.4.0",
|
||||
"@capacitor/device": "8.0.2",
|
||||
"@capacitor/filesystem": "8.1.2",
|
||||
"@capacitor/push-notifications": "8.1.1",
|
||||
"@capacitor/share": "8.0.1",
|
||||
"@capgo/capacitor-updater": "8.50.1",
|
||||
"@gouvfr-lasuite/cunningham-react": "4.3.1",
|
||||
@@ -2853,6 +2855,15 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/device": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/device/-/device-8.0.2.tgz",
|
||||
"integrity": "sha512-fIqSXnG0s6bz5A/0xFgSXDkbU+Xl65ti80LhucNvLI4kGhJzcNn6SwWVwpXN9SJTOFWXblXknHNppheP8X1frQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/filesystem": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.2.tgz",
|
||||
@@ -2875,6 +2886,15 @@
|
||||
"@capacitor/core": "^8.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/push-notifications": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.1.1.tgz",
|
||||
"integrity": "sha512-WqzjPKIbYbARMN+GC0XMAJcxJpUUzqgzS/Ny8RODLrro38pQhm3GXYwX2Mwd+LZlLY39rGImkCkrKyQSNfuikA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/share": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.1.tgz",
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
"@capacitor/app": "8.1.0",
|
||||
"@capacitor/browser": "8.0.3",
|
||||
"@capacitor/core": "8.4.0",
|
||||
"@capacitor/device": "8.0.2",
|
||||
"@capacitor/filesystem": "8.1.2",
|
||||
"@capacitor/push-notifications": "8.1.1",
|
||||
"@capacitor/share": "8.0.1",
|
||||
"@capgo/capacitor-updater": "8.50.1",
|
||||
"@gouvfr-lasuite/cunningham-react": "4.3.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -180,6 +180,7 @@
|
||||
"Address shared between {{count}} members_other": "Address shared between {{count}} members",
|
||||
"Addresses": "Addresses",
|
||||
"After creating the widget, you will receive the installation code to add to your website.": "After creating the widget, you will receive the installation code to add to your website.",
|
||||
"Alerts for new messages in your mailboxes": "Alerts for new messages in your mailboxes",
|
||||
"All messages": "All messages",
|
||||
"All settings": "All settings",
|
||||
"All users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "All users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.",
|
||||
@@ -197,8 +198,10 @@
|
||||
"An unexpected error occurred.": "An unexpected error occurred.",
|
||||
"and {{count}} other users_one": "and 1 other user",
|
||||
"and {{count}} other users_other": "and {{count}} other users",
|
||||
"Android": "Android",
|
||||
"API Key": "API Key",
|
||||
"API key in header — for receivers that can only check a static header value": "API key in header — for receivers that can only check a static header value",
|
||||
"Apple (iPhone / iPad)": "Apple (iPhone / iPad)",
|
||||
"Archive": "Archive",
|
||||
"Archives": "Archives",
|
||||
"Are you sure you want to delete this auto-reply? This action is irreversible!": "Are you sure you want to delete this auto-reply? This action is irreversible!",
|
||||
@@ -290,6 +293,9 @@
|
||||
"Correct": "Correct",
|
||||
"Could not be delivered": "Could not be delivered",
|
||||
"Could not confirm the calendar update.": "Could not confirm the calendar update.",
|
||||
"Couldn't reach the browser's push service. Your network or company firewall may be blocking it. If you use Brave, enable “Use Google services for push messaging” in settings, restart the browser, then try again.": "Couldn't reach the browser's push service. Your network or company firewall may be blocking it. If you use Brave, enable “Use Google services for push messaging” in settings, restart the browser, then try again.",
|
||||
"Couldn't register this device for notifications. Check your connection and try again.": "Couldn't register this device for notifications. Check your connection and try again.",
|
||||
"Couldn't start the notification service worker. Reload the page and try again.": "Couldn't start the notification service worker. Reload the page and try again.",
|
||||
"Create": "Create",
|
||||
"Create a Label": "Create a Label",
|
||||
"Create a new address @{{domain}}": "Create a new address @{{domain}}",
|
||||
@@ -343,6 +349,9 @@
|
||||
"Description": "Description",
|
||||
"Description must be less than 255 characters.": "Description must be less than 255 characters.",
|
||||
"Deselect all threads": "Deselect all threads",
|
||||
"Device": "Device",
|
||||
"Device signed out.": "Device signed out.",
|
||||
"Devices where you receive push notifications. These are personal to you and span all your mailboxes.": "Devices where you receive push notifications. These are personal to you and span all your mailboxes.",
|
||||
"Did you forget an attachment?": "Did you forget an attachment?",
|
||||
"Disable thread selection": "Disable thread selection",
|
||||
"Display those images": "Display those images",
|
||||
@@ -374,7 +383,9 @@
|
||||
"edited": "edited",
|
||||
"Editing message": "Editing message",
|
||||
"Email address": "Email address",
|
||||
"Email address or username": "Email address or username",
|
||||
"EML, MBOX or PST": "EML, MBOX or PST",
|
||||
"Enable notifications on this device": "Enable notifications on this device",
|
||||
"End date": "End date",
|
||||
"End date is required": "End date is required",
|
||||
"End day": "End day",
|
||||
@@ -386,6 +397,7 @@
|
||||
"Error while checking DNS records": "Error while checking DNS records",
|
||||
"Error while loading addresses": "Error while loading addresses",
|
||||
"Error while loading auto-replies": "Error while loading auto-replies",
|
||||
"Error while loading devices": "Error while loading devices",
|
||||
"Error while loading imports": "Error while loading imports",
|
||||
"Error while loading integrations": "Error while loading integrations",
|
||||
"Error while loading signatures": "Error while loading signatures",
|
||||
@@ -410,14 +422,17 @@
|
||||
"Failed to delete signature.": "Failed to delete signature.",
|
||||
"Failed to delete template.": "Failed to delete template.",
|
||||
"Failed to download {{name}}.": "Failed to download {{name}}.",
|
||||
"Failed to enable notifications.": "Failed to enable notifications.",
|
||||
"Failed to load auto-reply. Please try again.": "Failed to load auto-reply. Please try again.",
|
||||
"Failed to load calendar invite": "Failed to load calendar invite",
|
||||
"Failed to load signature. Please try again.": "Failed to load signature. Please try again.",
|
||||
"Failed to load template. Please try again.": "Failed to load template. Please try again.",
|
||||
"Failed to refresh summary.": "Failed to refresh summary.",
|
||||
"Failed to rename device.": "Failed to rename device.",
|
||||
"Failed to save auto-reply. Please try again.": "Failed to save auto-reply. Please try again.",
|
||||
"Failed to save signature. Please try again.": "Failed to save signature. Please try again.",
|
||||
"Failed to save template. Please try again.": "Failed to save template. Please try again.",
|
||||
"Failed to sign out device.": "Failed to sign out device.",
|
||||
"Failed to update auto-reply.": "Failed to update auto-reply.",
|
||||
"Failed to update import.": "Failed to update import.",
|
||||
"Failed to update integration.": "Failed to update integration.",
|
||||
@@ -497,6 +512,7 @@
|
||||
"Label name": "Label name",
|
||||
"Labels": "Labels",
|
||||
"Last access": "Last access",
|
||||
"Last active": "Last active",
|
||||
"Last name": "Last name",
|
||||
"Last name is required.": "Last name is required.",
|
||||
"Last saved {{relativeTime}}": "Last saved {{relativeTime}}",
|
||||
@@ -511,6 +527,7 @@
|
||||
"Loading auto-replies...": "Loading auto-replies...",
|
||||
"Loading auto-reply...": "Loading auto-reply...",
|
||||
"Loading calendar invite...": "Loading calendar invite...",
|
||||
"Loading devices...": "Loading devices...",
|
||||
"Loading imports...": "Loading imports...",
|
||||
"Loading integrations...": "Loading integrations...",
|
||||
"Loading labels...": "Loading labels...",
|
||||
@@ -583,6 +600,7 @@
|
||||
"New import": "New import",
|
||||
"New integration": "New integration",
|
||||
"New message": "New message",
|
||||
"New messages": "New messages",
|
||||
"New signature": "New signature",
|
||||
"New template": "New template",
|
||||
"No accesses": "No accesses",
|
||||
@@ -591,6 +609,7 @@
|
||||
"No attachments": "No attachments",
|
||||
"No auto-replies": "No auto-replies",
|
||||
"No auto-reply": "No auto-reply",
|
||||
"No devices yet. Enable notifications on this device.": "No devices yet. Enable notifications on this device.",
|
||||
"No DNS records found": "No DNS records found",
|
||||
"No draft could be deleted.": "No draft could be deleted.",
|
||||
"No event found in calendar invite": "No event found in calendar invite",
|
||||
@@ -616,6 +635,12 @@
|
||||
"No thread could be starred.": "No thread could be starred.",
|
||||
"No threads": "No threads",
|
||||
"No threads match the active filters": "No threads match the active filters",
|
||||
"Notification permission was dismissed. Click again to enable.": "Notification permission was dismissed. Click again to enable.",
|
||||
"Notifications": "Notifications",
|
||||
"Notifications are blocked. Allow them for this app in your device settings.": "Notifications are blocked. Allow them for this app in your device settings.",
|
||||
"Notifications are blocked. Allow them for this site in your browser settings.": "Notifications are blocked. Allow them for this site in your browser settings.",
|
||||
"Notifications are not available on this server.": "Notifications are not available on this server.",
|
||||
"Notifications enabled on this device.": "Notifications enabled on this device.",
|
||||
"OK": "OK",
|
||||
"Older": "Older",
|
||||
"On going": "On going",
|
||||
@@ -670,6 +695,8 @@
|
||||
"Remove spam report": "Remove spam report",
|
||||
"Remove tag": "Remove tag",
|
||||
"Remove this access?": "Remove this access?",
|
||||
"Rename": "Rename",
|
||||
"Rename device": "Rename device",
|
||||
"Reply": "Reply",
|
||||
"Reply all": "Reply all",
|
||||
"Report as spam": "Report as spam",
|
||||
@@ -737,6 +764,8 @@
|
||||
"Show less": "Show less",
|
||||
"Show logs": "Show logs",
|
||||
"Show more": "Show more",
|
||||
"Sign out": "Sign out",
|
||||
"Sign out \"{{name}}\"": "Sign out \"{{name}}\"",
|
||||
"Signature created!": "Signature created!",
|
||||
"Signature deleted!": "Signature deleted!",
|
||||
"Signature updated!": "Signature updated!",
|
||||
@@ -807,9 +836,12 @@
|
||||
"This account will now be checked for new mail regularly.": "This account will now be checked for new mail regularly.",
|
||||
"This action cannot be undone and the user will need the new password to access its mailbox.": "This action cannot be undone and the user will need the new password to access its mailbox.",
|
||||
"This attachment isn't sent yet — it's part of the draft you're composing.": "This attachment isn't sent yet — it's part of the draft you're composing.",
|
||||
"This browser does not support notifications.": "This browser does not support notifications.",
|
||||
"This contact's identity could not be verified. Proceed with caution.": "This contact's identity could not be verified. Proceed with caution.",
|
||||
"This deletes every message this import created, except those in conversations with replies or other activity. This action is irreversible!": "This deletes every message this import created, except those in conversations with replies or other activity. This action is irreversible!",
|
||||
"This description will be used by the AI to automatically assign this label to your messages.": "This description will be used by the AI to automatically assign this label to your messages.",
|
||||
"This device does not support notifications.": "This device does not support notifications.",
|
||||
"This device will stop receiving notifications until you enable them again on it.": "This device will stop receiving notifications until you enable them again on it.",
|
||||
"This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "This email prefix is not allowed for personal mailboxes. Please choose a different prefix.",
|
||||
"This event has been cancelled": "This event has been cancelled",
|
||||
"This event has been cancelled by the organizer.": "This event has been cancelled by the organizer.",
|
||||
@@ -853,6 +885,7 @@
|
||||
"Tutorials and training": "Tutorials and training",
|
||||
"Type": "Type",
|
||||
"Unable to check task status.": "Unable to check task status.",
|
||||
"Unable to check the import status.": "Unable to check the import status.",
|
||||
"Unable to copy credentials.": "Unable to copy credentials.",
|
||||
"Unable to copy to clipboard.": "Unable to copy to clipboard.",
|
||||
"Unarchive": "Unarchive",
|
||||
@@ -892,6 +925,7 @@
|
||||
"View full documentation": "View full documentation",
|
||||
"Visit the Help center": "Visit the Help center",
|
||||
"We couldn't detect your IMAP server. Please enter it manually.": "We couldn't detect your IMAP server. Please enter it manually.",
|
||||
"Web browser": "Web browser",
|
||||
"Webhook": "Webhook",
|
||||
"Webhook API key": "Webhook API key",
|
||||
"Webhook signing secret": "Webhook signing secret",
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
"Address shared between {{count}} members_other": "Adresse partagée entre {{count}} membres",
|
||||
"Addresses": "Adresses",
|
||||
"After creating the widget, you will receive the installation code to add to your website.": "Après avoir créé le widget, vous recevrez le code d'installation à ajouter à votre site web.",
|
||||
"Alerts for new messages in your mailboxes": "Alertes des nouveaux messages de vos boîtes mail",
|
||||
"All messages": "Tous les messages",
|
||||
"All settings": "Tous les paramètres",
|
||||
"All users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "Tous les utilisateurs avec un accès à la boîte \"{{mailboxName}}\" ne verront plus ce thread.",
|
||||
@@ -270,8 +271,10 @@
|
||||
"and {{count}} other users_one": "et 1 autre utilisateur",
|
||||
"and {{count}} other users_many": "et {{count}} autres utilisateurs",
|
||||
"and {{count}} other users_other": "et {{count}} autres utilisateurs",
|
||||
"Android": "Android",
|
||||
"API Key": "Clé API",
|
||||
"API key in header — for receivers that can only check a static header value": "Clé API dans l'en-tête — pour les destinataires qui ne peuvent vérifier qu'une valeur d'en-tête statique",
|
||||
"Apple (iPhone / iPad)": "Apple (iPhone / iPad)",
|
||||
"Archive": "Archiver",
|
||||
"Archives": "Archives",
|
||||
"Are you sure you want to delete this auto-reply? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette réponse automatique ? Cette action est irréversible !",
|
||||
@@ -365,6 +368,9 @@
|
||||
"Correct": "Correct",
|
||||
"Could not be delivered": "N'a pas pu être délivré",
|
||||
"Could not confirm the calendar update.": "Impossible de confirmer la mise à jour du calendrier.",
|
||||
"Couldn't reach the browser's push service. Your network or company firewall may be blocking it. If you use Brave, enable “Use Google services for push messaging” in settings, restart the browser, then try again.": "Impossible de joindre le service de notifications push du navigateur. Votre réseau ou le pare-feu de votre entreprise le bloque peut-être. Si vous utilisez Brave, activez « Utiliser les services Google pour la messagerie push » dans les réglages, redémarrez le navigateur, puis réessayez.",
|
||||
"Couldn't register this device for notifications. Check your connection and try again.": "Impossible d’enregistrer cet appareil pour les notifications. Vérifiez votre connexion et réessayez.",
|
||||
"Couldn't start the notification service worker. Reload the page and try again.": "Impossible de démarrer le service worker de notifications. Rechargez la page et réessayez.",
|
||||
"Create": "Créer",
|
||||
"Create a Label": "Créer un libellé",
|
||||
"Create a new address @{{domain}}": "Création d'une nouvelle adresse @{{domain}}",
|
||||
@@ -418,6 +424,9 @@
|
||||
"Description": "Description",
|
||||
"Description must be less than 255 characters.": "La description ne peut pas excéder 255 caractères.",
|
||||
"Deselect all threads": "Désélectionner toutes les conversations",
|
||||
"Device": "Appareil",
|
||||
"Device signed out.": "Appareil déconnecté.",
|
||||
"Devices where you receive push notifications. These are personal to you and span all your mailboxes.": "Appareils sur lesquels vous recevez des notifications push. Ils vous sont personnels et s’appliquent à toutes vos boîtes aux lettres.",
|
||||
"Did you forget an attachment?": "N'avez-vous pas oublié une pièce jointe ?",
|
||||
"Disable thread selection": "Désactiver la sélection",
|
||||
"Display those images": "Afficher ces images",
|
||||
@@ -451,6 +460,7 @@
|
||||
"Email address": "Adresse mail",
|
||||
"Email address or username": "Adresse mail ou nom d'utilisateur",
|
||||
"EML, MBOX or PST": "EML, MBOX ou PST",
|
||||
"Enable notifications on this device": "Activer les notifications sur cet appareil",
|
||||
"End date": "Date de fin",
|
||||
"End date is required": "La date de fin est requise",
|
||||
"End day": "Jour de fin",
|
||||
@@ -462,6 +472,7 @@
|
||||
"Error while checking DNS records": "Erreur lors de la vérification des enregistrements DNS",
|
||||
"Error while loading addresses": "Erreur lors du chargement des adresses",
|
||||
"Error while loading auto-replies": "Erreur lors du chargement des réponses automatiques",
|
||||
"Error while loading devices": "Erreur lors du chargement des appareils",
|
||||
"Error while loading imports": "Erreur lors du chargement des imports",
|
||||
"Error while loading integrations": "Erreur lors du chargement des intégrations",
|
||||
"Error while loading signatures": "Erreur lors du chargement des signatures",
|
||||
@@ -490,14 +501,17 @@
|
||||
"Failed to delete signature.": "Erreur lors de la suppression de la signature.",
|
||||
"Failed to delete template.": "Erreur lors de la suppression du modèle.",
|
||||
"Failed to download {{name}}.": "Échec du téléchargement de {{name}}.",
|
||||
"Failed to enable notifications.": "Échec de l’activation des notifications.",
|
||||
"Failed to load auto-reply. Please try again.": "Impossible de charger la réponse automatique. Veuillez réessayer.",
|
||||
"Failed to load calendar invite": "Impossible de charger l'invitation calendrier",
|
||||
"Failed to load signature. Please try again.": "Impossible de charger la signature. Veuillez réessayer.",
|
||||
"Failed to load template. Please try again.": "Impossible de charger le modèle. Veuillez réessayer.",
|
||||
"Failed to refresh summary.": "Erreur lors de la mise à jour du résumé.",
|
||||
"Failed to rename device.": "Échec du renommage de l'appareil.",
|
||||
"Failed to save auto-reply. Please try again.": "Erreur lors de la sauvegarde de la réponse automatique. Veuillez réessayer.",
|
||||
"Failed to save signature. Please try again.": "Erreur lors de la sauvegarde de la signature. Veuillez réessayer.",
|
||||
"Failed to save template. Please try again.": "Erreur lors de la sauvegarde du modèle. Veuillez réessayer.",
|
||||
"Failed to sign out device.": "Échec de la déconnexion de l’appareil.",
|
||||
"Failed to update auto-reply.": "Erreur lors de la mise à jour de la réponse automatique.",
|
||||
"Failed to update import.": "Échec de la mise à jour de l'import.",
|
||||
"Failed to update integration.": "Échec de la mise à jour de l'intégration.",
|
||||
@@ -539,9 +553,8 @@
|
||||
"Import": "Importer",
|
||||
"Import actions": "Actions sur l'import",
|
||||
"Import cancelled and messages deleted.": "Import annulé et messages supprimés.",
|
||||
"Import in progress": "Import en cours",
|
||||
"Import complete": "Importation terminée",
|
||||
"Import failed": "Importation échouée",
|
||||
"Import in progress": "Import en cours",
|
||||
"Import mail into this mailbox from an archive (PST, MBOX, EML) or an IMAP account, and track or cancel running imports.": "Importez des messages dans cette boîte depuis une archive (PST, MBOX, EML) ou un compte IMAP, et suivez ou annulez les imports en cours.",
|
||||
"Import messages": "Importer des messages",
|
||||
"Import removed from the list. Its messages were kept.": "Import retiré de la liste. Ses messages ont été conservés.",
|
||||
@@ -582,6 +595,7 @@
|
||||
"Label name": "Nom du libellé",
|
||||
"Labels": "Libellés",
|
||||
"Last access": "Dernier accès",
|
||||
"Last active": "Dernière activité",
|
||||
"Last name": "Nom",
|
||||
"Last name is required.": "Un nom est requis.",
|
||||
"Last saved {{relativeTime}}": "Dernière sauvegarde {{relativeTime}}",
|
||||
@@ -596,6 +610,7 @@
|
||||
"Loading auto-replies...": "Chargement des réponses automatiques...",
|
||||
"Loading auto-reply...": "Chargement de la réponse automatique...",
|
||||
"Loading calendar invite...": "Chargement de l'invitation calendrier...",
|
||||
"Loading devices...": "Chargement des appareils…",
|
||||
"Loading imports...": "Chargement des imports...",
|
||||
"Loading integrations...": "Chargement des intégrations...",
|
||||
"Loading labels...": "Chargement des libellés...",
|
||||
@@ -669,6 +684,7 @@
|
||||
"New import": "Nouvel import",
|
||||
"New integration": "Nouvelle intégration",
|
||||
"New message": "Nouveau message",
|
||||
"New messages": "Nouveaux messages",
|
||||
"New signature": "Nouvelle signature",
|
||||
"New template": "Nouveau modèle",
|
||||
"No accesses": "Aucun accès",
|
||||
@@ -677,6 +693,7 @@
|
||||
"No attachments": "Aucune pièce jointe",
|
||||
"No auto-replies": "Aucune réponse automatique",
|
||||
"No auto-reply": "Aucune réponse automatique",
|
||||
"No devices yet. Enable notifications on this device.": "Aucun appareil pour le moment. Activez les notifications sur cet appareil.",
|
||||
"No DNS records found": "Aucun enregistrement DNS trouvé",
|
||||
"No draft could be deleted.": "Aucun brouillon n'a pu être supprimé.",
|
||||
"No event found in calendar invite": "Aucun événement trouvé dans l'invitation calendrier",
|
||||
@@ -702,6 +719,12 @@
|
||||
"No thread could be starred.": "Aucune conversation n'a pu être suivie.",
|
||||
"No threads": "Aucune conversation",
|
||||
"No threads match the active filters": "Aucune conversation ne correspond aux filtres actifs",
|
||||
"Notification permission was dismissed. Click again to enable.": "L’autorisation de notification a été ignorée. Cliquez à nouveau pour l’activer.",
|
||||
"Notifications": "Notifications",
|
||||
"Notifications are blocked. Allow them for this app in your device settings.": "Les notifications sont bloquées. Autorisez-les pour cette application dans les réglages de votre appareil.",
|
||||
"Notifications are blocked. Allow them for this site in your browser settings.": "Les notifications sont bloquées. Autorisez-les pour ce site dans les paramètres de votre navigateur.",
|
||||
"Notifications are not available on this server.": "Les notifications ne sont pas disponibles sur ce serveur.",
|
||||
"Notifications enabled on this device.": "Notifications activées sur cet appareil.",
|
||||
"OK": "OK",
|
||||
"Older": "Plus ancien",
|
||||
"On going": "En cours",
|
||||
@@ -756,6 +779,8 @@
|
||||
"Remove spam report": "Annuler le signalement spam",
|
||||
"Remove tag": "Supprimer le tag",
|
||||
"Remove this access?": "Supprimer cet accès ?",
|
||||
"Rename": "Renommer",
|
||||
"Rename device": "Renommer l'appareil",
|
||||
"Reply": "Répondre",
|
||||
"Reply all": "Répondre à tous",
|
||||
"Report as spam": "Signaler comme spam",
|
||||
@@ -826,6 +851,8 @@
|
||||
"Show less": "Afficher moins",
|
||||
"Show logs": "En savoir plus",
|
||||
"Show more": "Afficher plus",
|
||||
"Sign out": "Déconnecter",
|
||||
"Sign out \"{{name}}\"": "",
|
||||
"Signature created!": "Signature créée !",
|
||||
"Signature deleted!": "Signature supprimée !",
|
||||
"Signature updated!": "Signature mise à jour !",
|
||||
@@ -897,9 +924,12 @@
|
||||
"This account will now be checked for new mail regularly.": "Ce compte sera désormais synchronisé régulièrement.",
|
||||
"This action cannot be undone and the user will need the new password to access its mailbox.": "Cette action est irréversible et l'utilisateur aura besoin du nouveau mot de passe pour accéder à sa boîte aux lettres.",
|
||||
"This attachment isn't sent yet — it's part of the draft you're composing.": "Cette pièce jointe n'a pas encore été envoyée; elle fait partie d'un brouillon que vous êtes entrain de rédiger.",
|
||||
"This browser does not support notifications.": "Ce navigateur ne prend pas en charge les notifications.",
|
||||
"This contact's identity could not be verified. Proceed with caution.": "L'identité de ce contact n'a pas pu être vérifiée. Faites attention.",
|
||||
"This deletes every message this import created, except those in conversations with replies or other activity. This action is irreversible!": "Cela supprime tous les messages créés par cet import, sauf ceux des conversations ayant reçu des réponses ou une autre activité. Cette action est irréversible !",
|
||||
"This description will be used by the AI to automatically assign this label to your messages.": "Cette description sera utilisée par l'IA pour assigner automatiquement cette étiquette à vos messages.",
|
||||
"This device does not support notifications.": "Cet appareil ne prend pas en charge les notifications.",
|
||||
"This device will stop receiving notifications until you enable them again on it.": "Cet appareil cessera de recevoir des notifications jusqu’à ce que vous les réactiviez dessus.",
|
||||
"This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "Ce préfixe d'adresse n'est pas autorisé pour les boîtes aux lettres personnelles. Veuillez choisir un autre préfixe.",
|
||||
"This event has been cancelled": "Cet événement a été annulé",
|
||||
"This event has been cancelled by the organizer.": "Cet événement a été annulé par l'organisateur.",
|
||||
@@ -983,6 +1013,7 @@
|
||||
"View full documentation": "Voir la documentation complète",
|
||||
"Visit the Help center": "Consulter le centre d'aide",
|
||||
"We couldn't detect your IMAP server. Please enter it manually.": "Nous n'avons pas pu détecter votre serveur IMAP. Veuillez le renseigner manuellement.",
|
||||
"Web browser": "Navigateur web",
|
||||
"Webhook": "Webhook",
|
||||
"Webhook API key": "Clé API du Webhook",
|
||||
"Webhook signing secret": "Secret de signature du Webhook",
|
||||
|
||||
@@ -1,37 +1,56 @@
|
||||
{
|
||||
"API key in header — for receivers that can only check a static header value": "API-sleutel in header — voor ontvangers die alleen een statische headerwaarde kunnen controleren",
|
||||
"Authentication": "Authenticatie",
|
||||
"What we post in the request body.": "Wat we in de request-body verzenden.",
|
||||
"Create a Webhook": "Een Webhook maken",
|
||||
"Done": "Klaar",
|
||||
"Data will be POSTed to this URL in the format selected below.": "De gegevens worden via POST naar deze URL verzonden in het hieronder geselecteerde formaat.",
|
||||
"Edit Webhook": "Webhook bewerken",
|
||||
"Endpoint": "Endpoint",
|
||||
"Forward every incoming message to a URL of your choice.": "Stuur elk binnenkomend bericht door naar een URL naar keuze.",
|
||||
"How the receiver authenticates our requests. The credential is shown once at creation.": "Hoe de ontvanger onze verzoeken authenticeert. De toegangsgegevens worden eenmalig getoond bij het aanmaken.",
|
||||
"JMAP Email (full message, RFC 8621)": "JMAP Email (volledig bericht, RFC 8621)",
|
||||
"JMAP Email (metadata only, no body)": "JMAP Email (alleen metadata, geen body)",
|
||||
"Outbound Webhook": "Uitgaande webhook",
|
||||
"Payload format": "Payloadformaat",
|
||||
"Raw .eml (message/rfc822)": "Onbewerkte .eml (message/rfc822)",
|
||||
"Save this credential now": "Sla deze inloggegevens nu op",
|
||||
"Signed (HMAC + JWT) — recommended for receivers that can verify a signature": "Ondertekend (HMAC + JWT) — aanbevolen voor ontvangers die een handtekening kunnen verifiëren",
|
||||
"This value is shown only once. Configure your receiver with it before closing — you can rotate it later if you need a new one.": "Deze waarde wordt slechts eenmaal getoond. Configureer je ontvanger ermee voordat je sluit — je kunt deze later roteren als je een nieuwe nodig hebt.",
|
||||
"URL": "URL",
|
||||
"URL is required.": "URL is vereist.",
|
||||
"URL must include a valid host.": "De URL moet een geldige host bevatten.",
|
||||
"URL must start with http:// or https://": "URL moet beginnen met http:// of https://",
|
||||
"Webhook": "Webhook",
|
||||
"Webhook API key": "Webhook API-sleutel",
|
||||
"Webhook signing secret": "Webhook-ondertekeningsgeheim",
|
||||
"{{assignees}} was unassigned_one": "",
|
||||
"{{assignees}} was unassigned_other": "",
|
||||
"{{author}} assigned {{assignees}}_one": "",
|
||||
"{{author}} assigned {{assignees}}_other": "",
|
||||
"{{author}} assigned themself": "",
|
||||
"{{author}} assigned themself and {{assignees}}_one": "",
|
||||
"{{author}} assigned themself and {{assignees}}_other": "",
|
||||
"{{author}} assigned you": "",
|
||||
"{{author}} assigned you and {{assignees}}_one": "",
|
||||
"{{author}} assigned you and {{assignees}}_other": "",
|
||||
"{{author}} assigned you and themself": "",
|
||||
"{{author}} assigned you, themself and {{assignees}}_one": "",
|
||||
"{{author}} assigned you, themself and {{assignees}}_other": "",
|
||||
"{{author}} unassigned {{assignees}}_one": "",
|
||||
"{{author}} unassigned {{assignees}}_other": "",
|
||||
"{{author}} unassigned themself": "",
|
||||
"{{author}} unassigned themself and {{assignees}}_one": "",
|
||||
"{{author}} unassigned themself and {{assignees}}_other": "",
|
||||
"{{author}} unassigned you": "",
|
||||
"{{author}} unassigned you and {{assignees}}_one": "",
|
||||
"{{author}} unassigned you and {{assignees}}_other": "",
|
||||
"{{author}} unassigned you and themself": "",
|
||||
"{{author}} unassigned you, themself and {{assignees}}_one": "",
|
||||
"{{author}} unassigned you, themself and {{assignees}}_other": "",
|
||||
"{{count}} assignment changes_one": "",
|
||||
"{{count}} assignment changes_other": "",
|
||||
"{{count}} attachments_one": "{{count}} bijlage",
|
||||
"{{count}} attachments_other": "{{count}} bijlagen",
|
||||
"{{count}} auto-reply_one": "",
|
||||
"{{count}} auto-reply_other": "",
|
||||
"{{count}} conflicting events_one": "",
|
||||
"{{count}} conflicting events_other": "",
|
||||
"{{count}} days ago_one": "{{count}} dag geleden",
|
||||
"{{count}} days ago_other": "{{count}} dagen geleden",
|
||||
"{{count}} drafts have been deleted._one": "",
|
||||
"{{count}} drafts have been deleted._other": "",
|
||||
"{{count}} failed_one": "{{count}} mislukt",
|
||||
"{{count}} failed_other": "{{count}} mislukt",
|
||||
"{{count}} hours ago_one": "{{count}} uur geleden",
|
||||
"{{count}} hours ago_other": "{{count}} uur geleden",
|
||||
"{{count}} import_one": "{{count}} import",
|
||||
"{{count}} import_other": "{{count}} imports",
|
||||
"{{count}} integration_one": "",
|
||||
"{{count}} integration_other": "",
|
||||
"{{count}} message template_one": "",
|
||||
"{{count}} message template_other": "",
|
||||
"{{count}} messages_one": "{{count}} bericht",
|
||||
"{{count}} messages_other": "{{count}} berichten",
|
||||
"{{count}} messages are now starred._one": "",
|
||||
"{{count}} messages are now starred._other": "",
|
||||
"{{count}} messages assigned to you_one": "",
|
||||
"{{count}} messages assigned to you_other": "",
|
||||
"{{count}} messages have been archived._one": "Het bericht is gearchiveerd.",
|
||||
"{{count}} messages have been archived._other": "{{count}} berichten zijn gearchiveerd.",
|
||||
"{{count}} messages have been deleted._one": "Het bericht is verwijderd.",
|
||||
@@ -42,18 +61,60 @@
|
||||
"{{count}} messages have been updated._other": "{{count}} berichten zijn bijgewerkt.",
|
||||
"{{count}} messages imported_one": "{{count}} bericht geïmporteerd",
|
||||
"{{count}} messages imported_other": "{{count}} berichten geïmporteerd",
|
||||
"{{count}} messages mentioning you_one": "",
|
||||
"{{count}} messages mentioning you_other": "",
|
||||
"{{count}} messages of this thread have been deleted._one": "{{count}} bericht van dit kanaal is verwijderd.",
|
||||
"{{count}} messages of this thread have been deleted._other": "{{count}} berichten van dit kanaal is verwijderd.",
|
||||
"{{count}} messages were imported before the error._one": "{{count}} bericht werd geïmporteerd vóór de fout.",
|
||||
"{{count}} messages were imported before the error._other": "{{count}} berichten werden geïmporteerd vóór de fout.",
|
||||
"{{count}} minutes ago_one": "{{count}} minuut geleden",
|
||||
"{{count}} minutes ago_other": "{{count}} minuten geleden",
|
||||
"{{count}} months ago_one": "{{count}} maand geleden",
|
||||
"{{count}} months ago_other": "{{count}} maanden geleden",
|
||||
"{{count}} new message_one": "",
|
||||
"{{count}} new message_other": "",
|
||||
"{{count}} occurrences_one": "{{count}} gebeurtenis",
|
||||
"{{count}} occurrences_other": "{{count}} gebeurtenisen",
|
||||
"{{count}} of which are shared_one": "",
|
||||
"{{count}} of which are shared_other": "",
|
||||
"{{count}} out of {{total}} drafts have been deleted._one": "",
|
||||
"{{count}} out of {{total}} drafts have been deleted._other": "",
|
||||
"{{count}} out of {{total}} messages are now starred._one": "",
|
||||
"{{count}} out of {{total}} messages are now starred._other": "",
|
||||
"{{count}} out of {{total}} messages have been archived._one": "",
|
||||
"{{count}} out of {{total}} messages have been archived._other": "",
|
||||
"{{count}} out of {{total}} messages have been deleted._one": "",
|
||||
"{{count}} out of {{total}} messages have been deleted._other": "",
|
||||
"{{count}} out of {{total}} messages have been reported as spam._one": "",
|
||||
"{{count}} out of {{total}} messages have been reported as spam._other": "",
|
||||
"{{count}} out of {{total}} threads are now starred._one": "",
|
||||
"{{count}} out of {{total}} threads are now starred._other": "",
|
||||
"{{count}} out of {{total}} threads have been archived._one": "",
|
||||
"{{count}} out of {{total}} threads have been archived._other": "",
|
||||
"{{count}} out of {{total}} threads have been deleted._one": "",
|
||||
"{{count}} out of {{total}} threads have been deleted._other": "",
|
||||
"{{count}} out of {{total}} threads have been reported as spam._one": "",
|
||||
"{{count}} out of {{total}} threads have been reported as spam._other": "",
|
||||
"{{count}} results_one": "{{count}} resultaat",
|
||||
"{{count}} results_other": "{{count}} resultaten",
|
||||
"{{count}} results assigned to you_one": "",
|
||||
"{{count}} results assigned to you_other": "",
|
||||
"{{count}} results mentioning you_one": "",
|
||||
"{{count}} results mentioning you_other": "",
|
||||
"{{count}} selected threads_one": "{{count}} geselecteerde thread",
|
||||
"{{count}} selected threads_other": "{{count}} geselecteerde threads",
|
||||
"{{count}} signature_one": "",
|
||||
"{{count}} signature_other": "",
|
||||
"{{count}} starred messages_one": "",
|
||||
"{{count}} starred messages_other": "",
|
||||
"{{count}} starred messages mentioning you_one": "",
|
||||
"{{count}} starred messages mentioning you_other": "",
|
||||
"{{count}} starred results_one": "",
|
||||
"{{count}} starred results_other": "",
|
||||
"{{count}} starred results mentioning you_one": "",
|
||||
"{{count}} starred results mentioning you_other": "",
|
||||
"{{count}} threads are now starred._one": "",
|
||||
"{{count}} threads are now starred._other": "",
|
||||
"{{count}} threads have been archived._one": "De thread is gearchiveerd.",
|
||||
"{{count}} threads have been archived._other": "{{count}} berichten zijn gearchiveerd.",
|
||||
"{{count}} threads have been deleted._one": "De thread is verwijderd.",
|
||||
@@ -66,31 +127,66 @@
|
||||
"{{count}} threads have been unarchived._other": "{{count}} threads zijn gedearchiveerd.",
|
||||
"{{count}} threads have been updated._one": "De thread is bijgewerkt.",
|
||||
"{{count}} threads have been updated._other": "{{count}} threads zijn bijgewerkt.",
|
||||
"{{count}} unread_one": "",
|
||||
"{{count}} unread_other": "",
|
||||
"{{count}} unread messages_one": "",
|
||||
"{{count}} unread messages_other": "",
|
||||
"{{count}} unread messages mentioning you_one": "",
|
||||
"{{count}} unread messages mentioning you_other": "",
|
||||
"{{count}} unread results_one": "",
|
||||
"{{count}} unread results_other": "",
|
||||
"{{count}} unread results mentioning you_one": "",
|
||||
"{{count}} unread results mentioning you_other": "",
|
||||
"{{count}} unread starred messages_one": "",
|
||||
"{{count}} unread starred messages_other": "",
|
||||
"{{count}} unread starred messages mentioning you_one": "",
|
||||
"{{count}} unread starred messages mentioning you_other": "",
|
||||
"{{count}} unread starred results_one": "",
|
||||
"{{count}} unread starred results_other": "",
|
||||
"{{count}} unread starred results mentioning you_one": "",
|
||||
"{{count}} unread starred results mentioning you_other": "",
|
||||
"{{count}} weeks ago_one": "{{count}} week geleden",
|
||||
"{{count}} weeks ago_other": "{{count}} weken geleden",
|
||||
"{{count}} years ago_one": "{{count}} jaar geleden",
|
||||
"{{count}} years ago_other": "{{count}} jaren geleden",
|
||||
"{{date}} at {{time}}": "{{date}} om {{time}}",
|
||||
"{{name}} assigned to this thread": "",
|
||||
"{{name}} unassigned from this thread": "",
|
||||
"{{name}} will no longer have access to the mailbox \"{{mailboxName}}\".": "",
|
||||
"{{progress}}% imported": "{{progress}}% geïmporteerd",
|
||||
"2 columns": "2 kolommen",
|
||||
"2FA has been reset for {{mailbox}}.": "",
|
||||
"Abort upload": "Upload Afbreken",
|
||||
"Accept": "",
|
||||
"Accepted": "Geaccepteerd",
|
||||
"Access sharing": "",
|
||||
"Access sharing to the mailbox": "",
|
||||
"Accesses": "Toegang",
|
||||
"Actions": "Acties",
|
||||
"Active": "Actief",
|
||||
"Active filters: {{filters}}": "",
|
||||
"Add a contact form widget to your website to receive messages directly in your mailbox.": "Voeg een contactformulier widget toe aan je website om direct berichten in je mailbox te ontvangen.",
|
||||
"Add a domain": "Domein toevoegen",
|
||||
"Add a member": "",
|
||||
"Add a sub-label": "Een sublabel toevoegen",
|
||||
"Add attachment from {{driveAppName}}": "Bijlage toevoegen van {{driveAppName}}",
|
||||
"Add attachments": "Bijlage toevoegen",
|
||||
"Add internal comment...": "",
|
||||
"Add label": "Label toevoegen",
|
||||
"Add labels": "Labels toevoegen",
|
||||
"Add tags": "Labels toevoegen",
|
||||
"Add this code snippet to your website to display the feedback widget.": "Voeg deze code snippet toe aan uw website om de feedbackwidget weer te geven.",
|
||||
"Add to calendar": "",
|
||||
"Address": "Adres",
|
||||
"Address shared between {{count}} members_one": "",
|
||||
"Address shared between {{count}} members_other": "",
|
||||
"Addresses": "Adressen",
|
||||
"After creating the widget, you will receive the installation code to add to your website.": "Na het maken van de widget ontvangt u de installatiecode om uw website toe te voegen.",
|
||||
"Alerts for new messages in your mailboxes": "",
|
||||
"All messages": "Alle berichten",
|
||||
"All settings": "",
|
||||
"All users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "",
|
||||
"Always": "",
|
||||
"An address with this prefix already exists in this domain.": "Een adres met dit voorvoegsel bestaat al in dit domein.",
|
||||
"An error occurred while creating the address.": "Fout opgetreden tijdens het aanmaken van uw adres.",
|
||||
"An error occurred while creating the domain.": "Fout opgetreden tijdens het aanmaken van het domein.",
|
||||
@@ -99,47 +195,76 @@
|
||||
"An error occurred while resetting the password.": "Er is een fout opgetreden tijdens het resetten van het wachtwoord.",
|
||||
"An error occurred while saving the integration.": "Er is een fout opgetreden bij het opslaan van de integratie.",
|
||||
"An error occurred while updating the address.": "Er is een fout opgetreden tijdens het bijwerken van het adres.",
|
||||
"An error occurred while updating the mailbox name.": "",
|
||||
"An error occurred while uploading the archive file.": "Er is een fout opgetreden tijdens het uploaden van het archiefbestand.",
|
||||
"An unexpected error occurred.": "Er deed zich een onverwachte fout voor.",
|
||||
"and {{count}} other users_one": "en 1 andere gebruiker",
|
||||
"and {{count}} other users_other": "en {{count}} andere gebruikers",
|
||||
"Android": "",
|
||||
"API Key": "API Key",
|
||||
"API key in header — for receivers that can only check a static header value": "API-sleutel in header — voor ontvangers die alleen een statische headerwaarde kunnen controleren",
|
||||
"Apple (iPhone / iPad)": "",
|
||||
"Archive": "Archief",
|
||||
"Archives": "Archieven",
|
||||
"Are you sure you want to delete this auto-reply? This action is irreversible!": "",
|
||||
"Are you sure you want to delete this draft? This action cannot be undone.": "Weet u zeker dat u dit concept wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"Are you sure you want to delete this integration? This action is irreversible!": "Weet je zeker dat je deze integratie wilt verwijderen? Deze actie is onomkeerbaar!",
|
||||
"Are you sure you want to delete this internal comment? It will be deleted for all users. This action cannot be undone.": "",
|
||||
"Are you sure you want to delete this label? This action is irreversible!": "Weet je zeker dat je dit label wilt verwijderen? Deze actie is onomkeerbaar!",
|
||||
"Are you sure you want to delete this mailbox? This action is irreversible!": "Weet u zeker dat u deze mailbox wilt verwijderen? Deze actie is onomkeerbaar!",
|
||||
"Are you sure you want to delete this signature? This action is irreversible!": "Weet je zeker dat je deze handtekening wilt verwijderen? Deze actie is onomkeerbaar!",
|
||||
"Are you sure you want to delete this template? This action is irreversible!": "Weet je zeker dat je deze template wilt verwijderen? Deze actie is onomkeerbaar!",
|
||||
"Are you sure you want to reset the password?": "Weet je zeker dat je het wachtwoord wilt resetten?",
|
||||
"Assign": "",
|
||||
"Assign this label": "",
|
||||
"Assign this label and archive": "",
|
||||
"Assign to...": "",
|
||||
"Assign users to this thread": "",
|
||||
"Assigned to {{count}} people_one": "",
|
||||
"Assigned to {{count}} people_other": "",
|
||||
"Assigned to {{names}}": "",
|
||||
"Assigned to me": "",
|
||||
"Assigned to this thread": "",
|
||||
"At least one recipient is required.": "Ten minste één ontvanger is vereist.",
|
||||
"Attachment failed to be saved into your {{driveAppName}}'s workspace.": "Bijlage kon niet worden opgeslagen in de {{driveAppName}}'s werkruimte.",
|
||||
"Attachment saved into your {{driveAppName}}'s workspace.": "Bijlage opgeslagen in uw {{driveAppName}}'s werkruimte.",
|
||||
"Attachment size limit exceeded": "Limiet voor bijlage overschreden",
|
||||
"Attachments": "Bijlagen",
|
||||
"Attachments must be less than {{size}}.": "Bijlagen moeten minder zijn dan {{size}}.",
|
||||
"Authentication": "Authenticatie",
|
||||
"Authentication failed. Please check your credentials and ensure you have enabled IMAP connections in your account.": "Verificatie is mislukt. Controleer uw inloggegevens en zorg ervoor dat IMAP-verbindingen in uw account zijn ingeschakeld.",
|
||||
"Auto-labeling": "Auto-labeling",
|
||||
"Auto-replies": "",
|
||||
"Auto-reply created!": "",
|
||||
"Auto-reply deleted!": "",
|
||||
"Auto-reply is active": "",
|
||||
"Auto-reply updated!": "",
|
||||
"Automatically create mailboxes according to OIDC emails": "Automatisch mailboxen aanmaken volgens OIDC e-mails",
|
||||
"Awaiting response": "In afwachting van antwoord",
|
||||
"Back": "Terug",
|
||||
"Back to imports": "Terug naar imports",
|
||||
"Back to your inbox": "Terug naar uw inbox",
|
||||
"bcc": "",
|
||||
"BCC: ": "BCC: ",
|
||||
"Blind copy: ": "Blinde kopie: ",
|
||||
"Calendar invite": "Kalender uitnodiging",
|
||||
"Calendar service unavailable": "",
|
||||
"Cancel": "Annuleren",
|
||||
"Cancel import and delete its messages": "Import annuleren en berichten verwijderen",
|
||||
"Cancel those sendings": "Annuleer de verzendingen",
|
||||
"Cancelled": "Geannuleerd",
|
||||
"Cannot add attachment(s). Total size would be more than {{maxSize}}.": "Kan bijlagen niet toevoegen. Totale grootte zou groter zijn dan {{maxSize}}.",
|
||||
"Cannot add image. File size exceeds the {{maxSize}} limit.": "Kan afbeelding niet toevoegen. Bestandsgrootte overschrijdt de {{maxSize}} limiet.",
|
||||
"cc": "",
|
||||
"CC: ": "CC: ",
|
||||
"Check DNS again": "Controleer DNS opnieuw",
|
||||
"Check for new mail regularly": "Regelmatig controleren op nieuwe e-mail",
|
||||
"Checking DNS records...": "DNS-records controleren...",
|
||||
"Checking every {{count}} min_one": "Controle elke {{count}} min",
|
||||
"Checking every {{count}} min_other": "Controle elke {{count}} min",
|
||||
"Choose calendar": "",
|
||||
"Choose the type of integration you want to create": "Kies het type integratie dat u wilt maken",
|
||||
"Clear filters": "",
|
||||
"Clear selected items": "Geselecteerde items wissen",
|
||||
"Click to add accesses": "Klik om toegang toe te voegen",
|
||||
"Close": "Sluit",
|
||||
@@ -148,10 +273,13 @@
|
||||
"Close the menu": "Menu sluiten",
|
||||
"Close this thread": "Sluit dit kanaal",
|
||||
"Collapse": "Inklappen",
|
||||
"Collapse {{name}}": "",
|
||||
"Collapse all": "Alles inklappen",
|
||||
"Color: ": "Kleur: ",
|
||||
"Coming soon": "Binnenkort beschikbaar",
|
||||
"Conflicting": "Conflict",
|
||||
"Connect external tools to this mailbox through widgets and API keys.": "",
|
||||
"Connecting to calendar…": "",
|
||||
"Contact the Support team": "Neem contact op met onze klantenservice",
|
||||
"Contains the words": "Bevat de woorden",
|
||||
"Content is required": "Inhoud is verplicht",
|
||||
@@ -159,6 +287,9 @@
|
||||
"Copied to clipboard": "Kopieer naar klembord",
|
||||
"Copy": "Kopieer",
|
||||
"Copy all DNS records": "Kopieer alle DNS records",
|
||||
"Copy link to comment": "",
|
||||
"Copy link to message": "",
|
||||
"Copy link to thread": "",
|
||||
"Copy to clipboard": "Kopieer naar klembord",
|
||||
"Copy: ": "Kopieer: ",
|
||||
"Correct": "Corrigeren",
|
||||
@@ -170,6 +301,7 @@
|
||||
"Create": "Creëren",
|
||||
"Create a Label": "Label aanmaken",
|
||||
"Create a new address @{{domain}}": "Maak een nieuw adres @{{domain}}",
|
||||
"Create a new auto-reply": "",
|
||||
"Create a new integration": "Een nieuwe integratie aanmaken",
|
||||
"Create a new label": "Nieuw label aanmaken",
|
||||
"Create a new personal mailbox": "Maak een nieuwe persoonlijke mailbox",
|
||||
@@ -178,42 +310,60 @@
|
||||
"Create a new signature for {{domain}}": "Maak een nieuwe handtekening voor {{domain}}",
|
||||
"Create a new template": "Maak een nieuwe sjabloon",
|
||||
"Create a simple redirect (Coming soon)": "Creëer een eenvoudige doorverwijzing (binnenkort beschikbaar)",
|
||||
"Create a Webhook": "Een Webhook maken",
|
||||
"Create a Widget": "Maak een widget",
|
||||
"Create integration": "Integratie aanmaken",
|
||||
"Create one": "",
|
||||
"Create reusable message templates shared by all users of this mailbox.": "",
|
||||
"Create standardized signatures that can be used by all users of this mailbox.": "",
|
||||
"Create the label \"{{label}}\"": "Maak label \"{{label}}\"",
|
||||
"create_mailbox_modal.success.credential_text": "Uw Messages credentials zijn:\n- E-mail: {{id}}\n- Wachtwoord: {{password}}\n\nHet zal worden gevraagd om uw wachtwoord te wijzigen bij uw eerste aanmelding.",
|
||||
"Created at": "Gemaakt op",
|
||||
"Creating...": "Maken...",
|
||||
"Credentials copied!": "Gegevens gekopieerd!",
|
||||
"Current status": "Huidige status",
|
||||
"Customize your sender name": "",
|
||||
"Daily": "Dagelijks",
|
||||
"Data will be POSTed to this URL in the format selected below.": "De gegevens worden via POST naar deze URL verzonden in het hieronder geselecteerde formaat.",
|
||||
"Date range": "",
|
||||
"Date:": "Datum:",
|
||||
"Date: ": "Datum: ",
|
||||
"Decline": "",
|
||||
"Declined": "Afgewezen",
|
||||
"Default": "Standaard",
|
||||
"Default signature": "Standaard handtekening",
|
||||
"Delegated": "Overgedragen",
|
||||
"Delete": "Verwijderen",
|
||||
"Delete auto-reply \"{{autoreply}}\"": "",
|
||||
"Delete draft": "Concept verwijderen",
|
||||
"Delete drafts": "",
|
||||
"Delete imported messages": "Geïmporteerde berichten verwijderen",
|
||||
"Delete integration \"{{name}}\"": "Integratie verwijderen \"{{name}}\"",
|
||||
"Delete internal comment": "",
|
||||
"Delete label \"{{label}}\"": "Verwijder label \"{{label}}\"",
|
||||
"Delete mailbox {{mailbox}}": "Verwijder mailbox {{mailbox}}",
|
||||
"Delete signature \"{{signature}}\"": "Verwijder handtekening \"{{signature}}\"",
|
||||
"Delete template \"{{template}}\"": "Sjabloon \"{{template}} \" verwijderen",
|
||||
"Delete the messages of \"{{name}}\"": "Berichten van \"{{name}}\" verwijderen",
|
||||
"Delivering": "Leveren",
|
||||
"Delivery cancelled": "Levering geannuleerd",
|
||||
"Delivery failed": "Levering mislukt",
|
||||
"Description": "Beschrijving",
|
||||
"Description must be less than 255 characters.": "Beschrijving moet minder dan 255 tekens bevatten.",
|
||||
"Deselect all threads": "Deselecteer alle threads",
|
||||
"Device": "",
|
||||
"Device signed out.": "",
|
||||
"Devices where you receive push notifications. These are personal to you and span all your mailboxes.": "",
|
||||
"Did you forget an attachment?": "Ben je de bijlage vergeten?",
|
||||
"Disable thread selection": "Thread-selectie uitschakelen",
|
||||
"Display those images": "Deze afbeeldingen weergeven",
|
||||
"DNS": "DNS",
|
||||
"Do you have any feedback?": "Heeft u feedback?",
|
||||
"Do you want to continue?": "",
|
||||
"Domain": "Domein",
|
||||
"Domain admin": "Domein beheerder",
|
||||
"Domain not found": "Domein niet gevonden",
|
||||
"Done": "Klaar",
|
||||
"Download": "Download",
|
||||
"Download invitation": "Uitnodiging downloaden",
|
||||
"Download raw email": "Download raw email",
|
||||
@@ -227,18 +377,34 @@
|
||||
"Duplicate": "Dupliceer",
|
||||
"Edit": "Bewerken",
|
||||
"Edit {{mailbox}} address": "Bewerk {{mailbox}} adres",
|
||||
"Edit auto-reply \"{{autoreply}}\"": "",
|
||||
"Edit signature \"{{signature}}\"": "Wijzig handtekening \"{{signature}}\"",
|
||||
"Edit template \"{{template}}\"": "Sjabloon \"{{template}} \" verwijderen",
|
||||
"Edit Webhook": "Webhook bewerken",
|
||||
"Edit Widget": "Widget bewerken",
|
||||
"edited": "",
|
||||
"Editing message": "",
|
||||
"Email address": "E-mail adres",
|
||||
"Email address or username": "E-mailadres of gebruikersnaam",
|
||||
"EML, MBOX or PST": "EML, MBOX of PST",
|
||||
"Enable notifications on this device": "",
|
||||
"End date": "",
|
||||
"End date is required": "",
|
||||
"End day": "",
|
||||
"End day is required": "",
|
||||
"End time": "",
|
||||
"End time is required": "",
|
||||
"Endpoint": "Endpoint",
|
||||
"Enter the email addresses of the recipients separated by commas": "Voer de e-mailadressen in van de geadresseerden gescheiden door komma's",
|
||||
"Error while checking DNS records": "Fout bij het controleren van DNS records",
|
||||
"Error while loading addresses": "Fout bij het laden van adressen",
|
||||
"Error while loading auto-replies": "",
|
||||
"Error while loading devices": "",
|
||||
"Error while loading imports": "Fout bij het laden van imports",
|
||||
"Error while loading integrations": "Fout tijdens het laden van integraties",
|
||||
"Error while loading signatures": "Fout bij het laden van handtekeningen",
|
||||
"Error while loading templates": "Fout bij het laden van sjablonen",
|
||||
"Event added to calendar": "",
|
||||
"Every {{count}} days_one": "Elke {{count}} dagen",
|
||||
"Every {{count}} days_other": "Elke {{count}} dagen",
|
||||
"Every {{count}} months_one": "Elke {{count}} maanden",
|
||||
@@ -247,17 +413,31 @@
|
||||
"Every {{count}} weeks_other": "Elke {{count}} weken",
|
||||
"Every {{count}} years_one": "Elke {{count}} jaar",
|
||||
"Every {{count}} years_other": "Elke {{count}} jaren",
|
||||
"Existing 2FA credentials will be removed. The user will be asked to re-enroll on next login.": "",
|
||||
"Expand": "Uitklappen",
|
||||
"Expand {{name}}": "",
|
||||
"Expand all": "Alles uitklappen",
|
||||
"External link": "",
|
||||
"Failed": "Mislukt",
|
||||
"Failed to delete auto-reply.": "",
|
||||
"Failed to delete integration.": "Verwijderen van integratie mislukt.",
|
||||
"Failed to delete signature.": "Verwijderen van handtekening is mislukt.",
|
||||
"Failed to delete template.": "Verwijderen van sjabloon mislukt.",
|
||||
"Failed to download {{name}}.": "",
|
||||
"Failed to enable notifications.": "",
|
||||
"Failed to load auto-reply. Please try again.": "",
|
||||
"Failed to load calendar invite": "Laden agenda-uitnodiging mislukt",
|
||||
"Failed to load signature. Please try again.": "Laden van handtekening mislukt. Probeer het opnieuw.",
|
||||
"Failed to load template. Please try again.": "Laden sjabloon mislukt. Probeer het opnieuw.",
|
||||
"Failed to refresh summary.": "Vernieuwen samenvatting mislukt.",
|
||||
"Failed to rename device.": "",
|
||||
"Failed to save auto-reply. Please try again.": "",
|
||||
"Failed to save signature. Please try again.": "Handtekening opslaan mislukt. Probeer het opnieuw.",
|
||||
"Failed to save template. Please try again.": "Opslaan sjabloon mislukt. Probeer het opnieuw.",
|
||||
"Failed to sign out device.": "",
|
||||
"Failed to update auto-reply.": "",
|
||||
"Failed to update import.": "Bijwerken van import mislukt.",
|
||||
"Failed to update integration.": "Bijwerken van integratie mislukt.",
|
||||
"Failed to update signature.": "Bijwerken handtekening mislukt.",
|
||||
"Filter by: {{filters}}": "",
|
||||
"Filter threads": "",
|
||||
@@ -269,7 +449,9 @@
|
||||
"Forced": "Geforceerd",
|
||||
"Forced signature": "Geforceerde handtekening",
|
||||
"Forward": "Doorsturen",
|
||||
"Forward every incoming message to a URL of your choice.": "Stuur elk binnenkomend bericht door naar een URL naar keuze.",
|
||||
"Forwarded message": "Doorgestuurd bericht",
|
||||
"Friday": "",
|
||||
"From": "Van",
|
||||
"From:": "Van:",
|
||||
"From: ": "Van: ",
|
||||
@@ -278,6 +460,7 @@
|
||||
"General": "Algemeen",
|
||||
"Generate an API key to send messages programmatically from your applications.": "Genereer een API-sleutel om berichten programmatisch te verzenden vanuit uw applicaties.",
|
||||
"Generating summary...": "Samenvatting genereren...",
|
||||
"Grant editor access to the thread?": "",
|
||||
"Help center & Support": "Helpcentrum & Ondersteuning",
|
||||
"How the receiver authenticates our requests. The credential is shown once at creation.": "Hoe de ontvanger onze verzoeken authenticeert. De toegangsgegevens worden eenmalig getoond bij het aanmaken.",
|
||||
"How to allow IMAP connections from your account {{name}}?": "Hoe sta ik IMAP verbindingen toe van uw account {{name}}?",
|
||||
@@ -310,24 +493,44 @@
|
||||
"Insert image": "Afbeelding toevoegen",
|
||||
"Insert template": "Sjabloon invoegen",
|
||||
"Installation": "Installatie",
|
||||
"Integration \"{{name}}\" paused.": "Integratie \"{{name}}\" gepauzeerd.",
|
||||
"Integration \"{{name}}\" resumed.": "Integratie \"{{name}}\" hervat.",
|
||||
"Integration created!": "Integratie gemaakt!",
|
||||
"Integration deleted!": "Integratie verwijderd!",
|
||||
"Integration updated!": "Integratie bijgewerkt!",
|
||||
"Integrations": "Integraties",
|
||||
"Its actual content doesn't match its declared type. Open it only if you trust the sender.": "",
|
||||
"JMAP Email (full message, RFC 8621)": "JMAP Email (volledig bericht, RFC 8621)",
|
||||
"JMAP Email (metadata only, no body)": "JMAP Email (alleen metadata, geen body)",
|
||||
"just now": "zojuist",
|
||||
"Label \"{{label}}\" assigned and {{count}} threads archived._one": "",
|
||||
"Label \"{{label}}\" assigned and {{count}} threads archived._other": "",
|
||||
"Label \"{{label}}\" assigned to {{count}} threads._one": "Label \"{{label}}\" toegewezen aan dit gesprek.",
|
||||
"Label \"{{label}}\" assigned to {{count}} threads._other": "Label \"{{label}}\" toegewezen aan {{count}} threads.",
|
||||
"Label \"{{label}}\" assigned, but no threads could be archived.": "",
|
||||
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._one": "",
|
||||
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._other": "",
|
||||
"Label \"{{label}}\" removed from this conversation.": "Label \"{{label}}\" verwijderd uit dit gesprek.",
|
||||
"Label name": "Label naam",
|
||||
"Labels": "Labels",
|
||||
"Last access": "",
|
||||
"Last active": "",
|
||||
"Last name": "Achternaam",
|
||||
"Last name is required.": "Achternaam is vereist.",
|
||||
"Last saved {{relativeTime}}": "Laatst opgeslagen {{relativeTime}}",
|
||||
"Last update: {{timestamp}}": "Laatst bijgewerkt: {{timestamp}}",
|
||||
"Layout": "Layout",
|
||||
"Leave this mailbox?": "",
|
||||
"Leave this thread": "",
|
||||
"Leave this thread?": "",
|
||||
"less than a minute ago": "minder dan 1 minuut geleden",
|
||||
"Link copied to clipboard": "",
|
||||
"Loading addresses...": "Adressen laden...",
|
||||
"Loading auto-replies...": "",
|
||||
"Loading auto-reply...": "",
|
||||
"Loading calendar invite...": "Agenda-uitnodiging laden...",
|
||||
"Loading devices...": "",
|
||||
"Loading imports...": "Imports laden...",
|
||||
"Loading integrations...": "Integraties laden...",
|
||||
"Loading labels...": "Labels laden...",
|
||||
"Loading next threads...": "Volgende kanaal laden...",
|
||||
@@ -336,43 +539,72 @@
|
||||
"Loading tags...": "Tags laden...",
|
||||
"Loading template...": "Sjabloon laden...",
|
||||
"Loading templates...": "Sjablonen laden...",
|
||||
"Loading users": "",
|
||||
"Loading variables...": "Variabelen laden...",
|
||||
"Loading…": "Laden…",
|
||||
"logo": "",
|
||||
"Mailbox {{mailbox}} has been deleted successfully.": "Mailbox {{mailbox}} is succesvol verwijderd.",
|
||||
"Mailbox count": "",
|
||||
"Mailbox is required.": "Mailbox is vereist.",
|
||||
"Maildomains management": "Maildomeinen beheer",
|
||||
"Manage {{entity}} accesses": "{{entity}} toegang beheren",
|
||||
"Manage accesses": "Beheer toegang",
|
||||
"Mandatory 2FA": "",
|
||||
"Mandatory 2FA disabled for {{mailbox}}.": "",
|
||||
"Mandatory 2FA enabled for {{mailbox}}.": "",
|
||||
"Mark all as read": "Alles markeren als gelezen",
|
||||
"Mark all as unread": "Alles markeren als ongelezen",
|
||||
"Mark as read": "Markeren als gelezen",
|
||||
"Mark as read from here": "Markeer als gelezen vanaf hier",
|
||||
"Mark as unread": "Markeer als gelezen",
|
||||
"Mark as unread from here": "Markeer als ongelezen vanaf hier",
|
||||
"Maybe": "",
|
||||
"Mentioned": "",
|
||||
"Message content": "Bericht inhoud",
|
||||
"Message delivered (recommended) — fire after delivery, response ignored": "Bericht bezorgd (aanbevolen) — wordt geactiveerd na bezorging, antwoord wordt genegeerd",
|
||||
"Message delivering — blocking, after the spam check; can shape the message and sees the verdict": "Bericht wordt bezorgd — blokkerend, na de spamcontrole; kan het bericht aanpassen en ziet het oordeel",
|
||||
"Message from {referer_domain}": "Bericht van {referer_domain}",
|
||||
"Message inbound — blocking, before the spam check; can shape the message before it is scanned": "Bericht inkomend — blokkerend, vóór de spamcontrole; kan het bericht aanpassen voordat het wordt gescand",
|
||||
"Message sent successfully": "Bericht succesvol verzonden",
|
||||
"Message templates": "",
|
||||
"Messages": "",
|
||||
"Messages Logo": "",
|
||||
"Messaging": "Berichten",
|
||||
"Method": "Methode",
|
||||
"Missing": "Ontbreekt",
|
||||
"Modified": "",
|
||||
"Modify": "Wijzig",
|
||||
"Monday": "",
|
||||
"Monthly": "Maandelijks",
|
||||
"More": "Meer",
|
||||
"More options": "Meer opties",
|
||||
"More options (none available for this mailbox)": "",
|
||||
"Move {{count}} threads_one": "",
|
||||
"Move {{count}} threads_other": "",
|
||||
"Move to trash": "",
|
||||
"Name": "Naam",
|
||||
"Name is required": "Naam is verplicht",
|
||||
"Name is required.": "Naam is verplicht.",
|
||||
"Name must be a valid domain name.": "Naam moet een geldige domeinnaam zijn.",
|
||||
"New address": "Nieuw adres",
|
||||
"New auto-reply": "",
|
||||
"New domain": "Nieuw domein",
|
||||
"New import": "Nieuwe import",
|
||||
"New integration": "Nieuwe integratie",
|
||||
"New message": "Nieuw bericht",
|
||||
"New messages": "",
|
||||
"New signature": "Nieuwe handtekening",
|
||||
"New template": "Nieuw sjabloon",
|
||||
"No access": "",
|
||||
"No accesses": "Geen toegangen",
|
||||
"No action available for this mailbox": "",
|
||||
"No addresses": "",
|
||||
"No attachments": "Geen bijlagen",
|
||||
"No auto-replies": "",
|
||||
"No auto-reply": "",
|
||||
"No devices yet. Enable notifications on this device.": "Nog geen apparaten. Schakel meldingen in op dit apparaat.",
|
||||
"No DNS records found": "Geen DNS-records gevonden",
|
||||
"No draft could be deleted.": "",
|
||||
"No event found in calendar invite": "Geen afspraak gevonden in agenda uitnodiging",
|
||||
"No imports": "",
|
||||
"No imports yet": "Nog geen imports",
|
||||
@@ -389,46 +621,108 @@
|
||||
"No signatures": "Geen handtekeningen",
|
||||
"No subject": "Geen onderwerp",
|
||||
"No summary available.": "Geen samenvatting beschikbaar.",
|
||||
"No templates": "",
|
||||
"No thread could be archived.": "",
|
||||
"No thread could be deleted.": "",
|
||||
"No thread could be reported as spam.": "",
|
||||
"No thread could be starred.": "",
|
||||
"No threads": "",
|
||||
"No threads match the active filters": "",
|
||||
"Notification permission was dismissed. Click again to enable.": "",
|
||||
"Notifications": "",
|
||||
"Notifications are blocked. Allow them for this app in your device settings.": "",
|
||||
"Notifications are blocked. Allow them for this site in your browser settings.": "",
|
||||
"Notifications are not available on this server.": "",
|
||||
"Notifications enabled on this device.": "",
|
||||
"OK": "",
|
||||
"Older": "",
|
||||
"On going": "",
|
||||
"Only available for personal mailboxes in identity-synced domains.": "",
|
||||
"Open {{driveAppName}} preview": "Open {{driveAppName}} voorbeeld",
|
||||
"Open calendar": "",
|
||||
"Open filters": "Open filters",
|
||||
"Open in {{driveAppName}}": "",
|
||||
"Open the menu": "Menu openen",
|
||||
"Or": "Of",
|
||||
"or drag and drop some files": "of sleep enkele bestanden",
|
||||
"Organizer": "",
|
||||
"Other services...": "Andere diensten...",
|
||||
"Outbound Webhook": "Uitgaande webhook",
|
||||
"Outbox": "Postvak UIT",
|
||||
"Password": "Wachtwoord",
|
||||
"Password is required.": "Wachtwoord is vereist.",
|
||||
"Password reset successfully!": "Wachtwoord succesvol gereset!",
|
||||
"Pause integration": "Integratie pauzeren",
|
||||
"Pause polling": "Controle pauzeren",
|
||||
"Payload format": "Payloadformaat",
|
||||
"Personal mailbox": "Persoonlijke mailbox",
|
||||
"Pick a calendar that matches one of the invitees to respond.": "",
|
||||
"Please enter a valid email address.": "Vul een correct email-adres in.",
|
||||
"Polling paused": "Controle gepauzeerd",
|
||||
"Polling paused.": "Controle gepauzeerd.",
|
||||
"Polling resumed.": "Controle hervat.",
|
||||
"Prefix can only contain letters, numbers, dots, underscores and hyphens.": "Voorvoegsel kan alleen letters, cijfers, punten, onderstrepingstekens en koppeltekens bevatten.",
|
||||
"Prefix is required.": "Voorvoegsel is vereist.",
|
||||
"Preview {{name}}": "",
|
||||
"Print": "Afdrukken",
|
||||
"Provenance": "",
|
||||
"Raw .eml (message/rfc822)": "Onbewerkte .eml (message/rfc822)",
|
||||
"Read": "Lees",
|
||||
"Read state": "Lees status",
|
||||
"Read the webhook documentation for all technical details": "Lees de webhookdocumentatie voor alle technische details",
|
||||
"Read-only": "",
|
||||
"Received on": "",
|
||||
"Recurring": "Terugkerend",
|
||||
"Recurring weekly": "",
|
||||
"Redirection": "Omleiding",
|
||||
"Refresh": "Vernieuw",
|
||||
"Refresh summary": "Samenvatting vernieuwen",
|
||||
"Regenerate credential": "Toegangsgegevens opnieuw genereren",
|
||||
"Regenerating the credential invalidates the old one immediately. The receiver must be updated with the new value before it can verify webhooks again.": "Het opnieuw genereren van de toegangsgegevens maakt de oude direct ongeldig. De ontvanger moet met de nieuwe waarde worden bijgewerkt voordat webhooks weer geverifieerd kunnen worden.",
|
||||
"Regenerating the credential invalidates the old one immediately. The receiver must be updated with the new value before it can verify webhooks again. Continue?": "",
|
||||
"Remove": "Verwijderen",
|
||||
"Remove {{displayName}}": "",
|
||||
"Remove access?": "",
|
||||
"Remove from list (keep messages)": "Uit de lijst verwijderen (berichten behouden)",
|
||||
"Remove report": "Rapport verwijderen",
|
||||
"Remove spam report": "Spam rapport verwijderen",
|
||||
"Remove tag": "Verwijder tag",
|
||||
"Remove this access?": "",
|
||||
"Rename": "",
|
||||
"Rename device": "",
|
||||
"Reply": "Antwoorden",
|
||||
"Reply all": "Allen beantwoorden",
|
||||
"Report as spam": "Als spam melden",
|
||||
"Reset": "Reset",
|
||||
"Reset 2FA": "",
|
||||
"Reset 2FA for {{mailbox}}": "",
|
||||
"Reset password": "Reset wachtwoord",
|
||||
"Reset password of {{mailbox}}": "Reset wachtwoord van {{mailbox}}",
|
||||
"Response saved — the organizer will be notified": "",
|
||||
"Resume integration": "Integratie hervatten",
|
||||
"Resume polling": "Controle hervatten",
|
||||
"Retry": "Opnieuw proberen",
|
||||
"Saturday": "",
|
||||
"Save": "Opslaan",
|
||||
"Save changes": "Wijzigingen opslaan",
|
||||
"Save failed — retry": "",
|
||||
"Save in {{driveAppName}}": "",
|
||||
"Save into your {{driveAppName}}'s workspace": "Sla op in uw {{driveAppName}}'s workspace",
|
||||
"Save this credential now": "Sla deze inloggegevens nu op",
|
||||
"Saving...": "Opslaan...",
|
||||
"Schedule": "",
|
||||
"Scheduled": "",
|
||||
"Search": "Zoek",
|
||||
"Search a domain": "",
|
||||
"Search a label": "Label zoeken",
|
||||
"Search a mailbox": "",
|
||||
"Search a mailbox to share this thread with": "",
|
||||
"Search a tag": "Label zoeken",
|
||||
"Search by domain name…": "",
|
||||
"Search by name or address…": "",
|
||||
"Search in messages...": "Zoeken in berichten...",
|
||||
"Search results": "",
|
||||
"Search users": "",
|
||||
"See members of this thread ({{count}} members)_one": "Leden van deze thread bekijken ({{count}} leden)",
|
||||
"See members of this thread ({{count}} members)_other": "Leden van deze thread bekijken ({{count}} leden)",
|
||||
"Select a parent label": "Selecteer een parent label",
|
||||
@@ -439,18 +733,27 @@
|
||||
"Send and archive": "Verstuur en archiveer",
|
||||
"Send and receive your messages in an instant.": "Verstuur en ontvang uw berichten direct.",
|
||||
"Send Feedback": "Feedback Versturen",
|
||||
"Sending is taking longer than expected. You can track your message in the Outbox.": "",
|
||||
"Sending message...": "Bericht verzenden...",
|
||||
"Sent": "Verzonden",
|
||||
"Sent by {{name}}": "Verzonden door {{name}}",
|
||||
"Sent on": "",
|
||||
"Set up automatic replies sent to senders while the mailbox is unattended. Only one auto-reply can be active at a time.": "",
|
||||
"Settings": "Instellingen",
|
||||
"Share and assign the thread": "",
|
||||
"Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.": "Deel de inloggegevens van deze mailbox met zijn gebruiker. U moet deze veilig overdragen, bij voorkeur fysiek.",
|
||||
"Share the mailbox": "",
|
||||
"Share the new credentials to the user.": "Deel de nieuwe inloggegevens met de gebruiker.",
|
||||
"Share the thread": "",
|
||||
"Share your feedback here...": "Deel hier uw feedback...",
|
||||
"Shared between {{count}} mailboxes_one": "",
|
||||
"Shared between {{count}} mailboxes_other": "",
|
||||
"Shared mailbox": "Gedeelde mailbox",
|
||||
"Show": "Toon",
|
||||
"Show {{count}} more_one": "Toon {{count}} meer",
|
||||
"Show {{count}} more_other": "Toon {{count}} meer",
|
||||
"Show embedded message": "Toon ingesloten bericht",
|
||||
"Show in conversation": "",
|
||||
"Show less": "Toon minder",
|
||||
"Show logs": "Toon logs",
|
||||
"Show more": "Toon meer",
|
||||
@@ -461,6 +764,7 @@
|
||||
"Signature deleted!": "Handtekening verwijderd!",
|
||||
"Signature updated!": "Handtekening bijgewerkt!",
|
||||
"Signatures": "Handtekeningen",
|
||||
"Signed (HMAC + JWT) — recommended for receivers that can verify a signature": "Ondertekend (HMAC + JWT) — aanbevolen voor ontvangers die een handtekening kunnen verifiëren",
|
||||
"Simple and intuitive messaging": "Eenvoudige en intuïtieve berichten",
|
||||
"Simple redirect (Coming soon)": "Eenvoudige doorverwijzing (binnenkort beschikbaar)",
|
||||
"Skip to main content": "Ga naar hoofdinhoud",
|
||||
@@ -483,6 +787,8 @@
|
||||
"Start time": "",
|
||||
"Start time is required": "",
|
||||
"Start typing...": "Begin met typen...",
|
||||
"Starting…": "Starten…",
|
||||
"Status": "Status",
|
||||
"Subject": "Onderwerp",
|
||||
"Subject template": "Onderwerp sjabloon",
|
||||
"Subject template is required.": "Onderwerp sjabloon is vereist.",
|
||||
@@ -491,9 +797,11 @@
|
||||
"Summarize": "Vat samen",
|
||||
"Summary": "Samenvatting",
|
||||
"Summary refreshed!": "Samenvatting vernieuwd!",
|
||||
"Sunday": "",
|
||||
"Synchronize mailboxes with an identity provider": "Synchroniseer mailboxen met een identiteitsprovider",
|
||||
"Tags": "Tags",
|
||||
"Target": "Target",
|
||||
"Target calendar": "",
|
||||
"Target email": "Target email",
|
||||
"Template created!": "Sjabloon aangemaakt!",
|
||||
"Template deleted!": "Sjabloon verwijderd!",
|
||||
@@ -506,18 +814,35 @@
|
||||
"The domain <strong>{{domain}}</strong> has been created successfully.": "Het domein <strong>{{domain}}</strong> is succesvol aangemaakt.",
|
||||
"The email {{email}} is invalid.": "De e-mail {{email}} is ongeldig.",
|
||||
"The forced signature will be the only one usable for new messages.": "De gedwongen handtekening is de enige die bruikbaar is voor nieuwe berichten.",
|
||||
"The mailbox \"{{mailbox}}\" currently has read-only access on this thread. To assign {{user}} to it, edit permissions must be granted to this mailbox.": "",
|
||||
"The mailbox name has been updated!": "",
|
||||
"The message could not be sent.": "Het bericht kon niet worden verzonden.",
|
||||
"The name must not exceed 255 characters.": "",
|
||||
"The organizer marked this event as tentative.": "",
|
||||
"The personal mailbox <strong>{{mailboxAddress}}</strong> has been created successfully.": "De persoonlijke mailbox <1>{{mailboxAddress}}</1> is succesvol gemaakt.",
|
||||
"The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.": "Het PST-archief is onleesbaar: het bestand is beschadigd of de interne structuur is onvolledig. Opnieuw proberen helpt niet — probeer het archief opnieuw te genereren.",
|
||||
"The redirect mailbox <strong>{{mailboxAddress}}</strong> has been created successfully.": "De redirect mailbox <1>{{mailboxAddress}}</1> is succesvol gemaakt.",
|
||||
"The shared mailbox <strong>{{mailboxAddress}}</strong> has been created successfully.": "De gedeelde mailbox <1>{{mailboxAddress}}</1> is succesvol gemaakt.",
|
||||
"The text of this link does not match its real target, it may be unsafe.": "",
|
||||
"The upload failed. Please try again.": "Upload mislukt, probeer het opnieuw.",
|
||||
"These DNS records must be configured on the domain <strong>{{domain}}</strong> for the mail system to work properly. Changes may take up to 24 hours to propagate. If you don't know how to update them, please contact your technical service provider or system administrator.": "",
|
||||
"These tags will be automatically applied to every incoming message from the widget.": "Deze tags worden automatisch toegepast op elk inkomend bericht vanuit de widget.",
|
||||
"This account will now be checked for new mail regularly.": "Dit account wordt voortaan regelmatig gecontroleerd op nieuwe e-mail.",
|
||||
"This action cannot be undone and the user will need the new password to access its mailbox.": "Deze actie kan niet ongedaan worden gemaakt en de gebruiker heeft het nieuwe wachtwoord nodig om toegang te krijgen tot de mailbox.",
|
||||
"This attachment isn't sent yet — it's part of the draft you're composing.": "",
|
||||
"This browser does not support notifications.": "",
|
||||
"This contact's identity could not be verified. Proceed with caution.": "De identiteit van deze contactpersoon kon niet worden geverifieerd. Wees voorzichtig.",
|
||||
"This deletes every message this import created, except those in conversations with replies or other activity. This action is irreversible!": "Dit verwijdert elk bericht dat deze import heeft aangemaakt, behalve berichten in gesprekken met antwoorden of andere activiteit. Deze actie is onomkeerbaar!",
|
||||
"This description will be used by the AI to automatically assign this label to your messages.": "Deze beschrijving wordt gebruikt door de AI om dit label automatisch aan je berichten toe te wijzen.",
|
||||
"This device does not support notifications.": "",
|
||||
"This device will stop receiving notifications until you enable them again on it.": "",
|
||||
"This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "Dit emailvoorvoegsel is niet toegestaan voor persoonlijke mailboxen. Kies een andere voorvoegsel.",
|
||||
"This event has been cancelled": "Deze afspraak is geannuleerd",
|
||||
"This event has been cancelled by the organizer.": "",
|
||||
"This file looks suspicious": "",
|
||||
"This is the mailbox owner, its access cannot be modified.": "",
|
||||
"This is the only admin of this mailbox, you cannot therefore modify its access.": "Dit is de enige admin van deze mailbox, u kunt daarom de toegang niet wijzigen.",
|
||||
"This message failed sender authentication and is likely a forgery. Do not trust it.": "",
|
||||
"This message has {{count}} attachments_one": "Deze e-mail heeft een bijlage",
|
||||
"This message has {{count}} attachments_other": "Dit bericht heeft {{count}} bijlagen",
|
||||
"This message has a draft": "Dit bericht heeft een concept",
|
||||
@@ -532,7 +857,15 @@
|
||||
"This name is for internal use only and will not be visible to users.": "Deze naam is alleen voor intern gebruik en is niet zichtbaar voor gebruikers.",
|
||||
"This signature is forced": "Deze handtekening is geforceerd",
|
||||
"This thread has been reported as spam.": "Deze discussie is gerapporteerd als spam.",
|
||||
"This thread has been reported as spam. For your security, previewing and downloading attachments has been disabled.": "",
|
||||
"This value is shown only once. Configure your receiver with it before closing — you can rotate it later if you need a new one.": "Deze waarde wordt slechts eenmaal getoond. Configureer je ontvanger ermee voordat je sluit — je kunt deze later roteren als je een nieuwe nodig hebt.",
|
||||
"This week": "",
|
||||
"This will move this message and all following messages to a new thread. Continue?": "",
|
||||
"Thread access removed": "Kanaal toegang verwijderd",
|
||||
"Thread has been split successfully.": "",
|
||||
"Thread list": "",
|
||||
"Thursday": "",
|
||||
"Timezone": "",
|
||||
"To": "Aan",
|
||||
"To be able to import emails from an IMAP server, you may need to allow IMAP access on your account.": "Om e-mails van een IMAP-server te kunnen importeren, moet je mogelijk IMAP-toegang op je account toestaan.",
|
||||
"To:": "Aan:",
|
||||
@@ -540,13 +873,17 @@
|
||||
"Today": "Vandaag",
|
||||
"Too large (over the size limit)": "Te groot (boven de groottelimiet)",
|
||||
"Trash": "Prullenbak",
|
||||
"Trigger": "Trigger",
|
||||
"Try again": "Opnieuw proberen",
|
||||
"Tuesday": "",
|
||||
"Tutorials and training": "Tutorials en training",
|
||||
"Type": "Type",
|
||||
"Unable to check task status.": "",
|
||||
"Unable to copy credentials.": "Kan de inloggegevens niet kopiëren.",
|
||||
"Unable to copy to clipboard.": "Kan niet kopiëren naar Klembord.",
|
||||
"Unarchive": "Dearchiveren",
|
||||
"Unassign": "",
|
||||
"Unassigned": "",
|
||||
"Undelete": "Terugzetten",
|
||||
"Undo": "Annuleren",
|
||||
"Unexpected error": "Onverwachte fout",
|
||||
@@ -557,25 +894,40 @@
|
||||
"Unread mention": "",
|
||||
"Unreadable or malformed": "Onleesbaar of misvormd",
|
||||
"Unsaved changes": "Niet-opgeslagen wijzigingen",
|
||||
"Unstar": "",
|
||||
"Unstar this thread": "",
|
||||
"until {{date}}": "tot {{date}}",
|
||||
"Up to date": "",
|
||||
"Update": "Bijwerken",
|
||||
"Update a Label": "Update een Label",
|
||||
"Updated at": "Bijgewerkt op",
|
||||
"Upload an archive": "Een archief uploaden",
|
||||
"Uploading... {{progress}}%": "Uploaden... {{progress}}%",
|
||||
"URL": "URL",
|
||||
"URL is required.": "URL is vereist.",
|
||||
"URL must include a valid host.": "De URL moet een geldige host bevatten.",
|
||||
"URL must start with http:// or https://": "URL moet beginnen met http:// of https://",
|
||||
"URL must start with https://": "URL moet beginnen met https://",
|
||||
"Use \"Send and archive\" by default": "Gebruik standaard \"Verstuur en archiveer\"",
|
||||
"Use {referer_domain} to include the website domain in the subject.": "Gebruik {referer_domain} om het website-domein in het onderwerp op te nemen.",
|
||||
"Use SSL": "Gebruik SSL",
|
||||
"Username is required.": "Gebruikersnaam is verplicht.",
|
||||
"Validate": "",
|
||||
"Value": "Waarde",
|
||||
"Variables": "Variabelen",
|
||||
"View full documentation": "Bekijk de volledige documentatie",
|
||||
"Visit the Help center": "Bezoek het helpcentrum",
|
||||
"We couldn't detect your IMAP server. Please enter it manually.": "We konden je IMAP-server niet detecteren. Voer deze handmatig in.",
|
||||
"Web browser": "",
|
||||
"Webhook": "Webhook",
|
||||
"Webhook API key": "Webhook API-sleutel",
|
||||
"Webhook signing secret": "Webhook-ondertekeningsgeheim",
|
||||
"Website Widget": "Websitewidget",
|
||||
"Wednesday": "",
|
||||
"Weekly": "Wekelijks",
|
||||
"What we post in the request body.": "Wat we in de request-body verzenden.",
|
||||
"Which point in the message's lifecycle fires this webhook, and whether it can influence delivery.": "Op welk punt in de levenscyclus van het bericht deze webhook wordt geactiveerd, en of deze de bezorging kan beïnvloeden.",
|
||||
"While the auto-reply is disabled, it will not be sent.": "",
|
||||
"While the signature is disabled, it will not be available to the users.": "Terwijl handtekening is uitgeschakeld, is niet beschikbaar voor de gebruikers.",
|
||||
"Widget": "Widget",
|
||||
"Yearly": "Jaarlijks",
|
||||
@@ -593,73 +945,24 @@
|
||||
"You assigned {{assignees}} and yourself_other": "",
|
||||
"You assigned yourself": "",
|
||||
"You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.": "U kunt de persoon nu informeren dat hun mailbox klaar is om te worden gebruikt en de instructies voor authenticatie communiceren.",
|
||||
"You can safely retry the import — messages already imported will not be duplicated.": "U kunt de import veilig opnieuw proberen — reeds geïmporteerde berichten worden niet gedupliceerd.",
|
||||
"You cannot delete the last editor of this thread": "U kunt de laatste bewerker van dit kanaal niet verwijderen",
|
||||
"You cannot modify it.": "Je kunt het niet wijzigen.",
|
||||
"You don't have a calendar yet.": "",
|
||||
"You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._one": "U heeft {{count}} ontvanger, wat hoger is dan het maximum van {{max}} ontvangers per bericht. Het bericht kan niet worden verzonden totdat u het aantal ontvangers vermindert.",
|
||||
"You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._other": "U heeft {{count}} ontvangers, die het maximum van {{max}} ontvangers per bericht overschrijden. Het bericht kan niet worden verzonden totdat u het aantal ontvangers vermindert.",
|
||||
"You have aborted the upload.": "Je hebt het uploaden afgebroken.",
|
||||
"You have unsaved changes. Are you sure you want to close?": "Je hebt niet-opgeslagen wijzigingen. Weet je zeker dat je wil annuleren?",
|
||||
"You left the thread": "",
|
||||
"You may not have sufficient permissions for all selected threads.": "",
|
||||
"You must confirm this statement.": "U moet deze verklaring bevestigen.",
|
||||
"You unassigned {{assignees}}_one": "",
|
||||
"You unassigned {{assignees}}_other": "",
|
||||
"You unassigned {{assignees}} and yourself_one": "",
|
||||
"You unassigned {{assignees}} and yourself_other": "",
|
||||
"You unassigned yourself": "",
|
||||
"You were unassigned": "",
|
||||
"You will no longer have access to the mailbox \"{{mailboxName}}\".": "",
|
||||
"Your email...": "Jouw email...",
|
||||
"Your session has expired. Please log in again.": "Je sessie is verlopen. Log opnieuw in.",
|
||||
"URL must start with https://": "URL moet beginnen met https://",
|
||||
"Regenerate credential": "Toegangsgegevens opnieuw genereren",
|
||||
"Regenerating the credential invalidates the old one immediately. The receiver must be updated with the new value before it can verify webhooks again.": "Het opnieuw genereren van de toegangsgegevens maakt de oude direct ongeldig. De ontvanger moet met de nieuwe waarde worden bijgewerkt voordat webhooks weer geverifieerd kunnen worden.",
|
||||
"Trigger": "Trigger",
|
||||
"Which point in the message's lifecycle fires this webhook, and whether it can influence delivery.": "Op welk punt in de levenscyclus van het bericht deze webhook wordt geactiveerd, en of deze de bezorging kan beïnvloeden.",
|
||||
"Message inbound — blocking, before the spam check; can shape the message before it is scanned": "Bericht inkomend — blokkerend, vóór de spamcontrole; kan het bericht aanpassen voordat het wordt gescand",
|
||||
"Message delivering — blocking, after the spam check; can shape the message and sees the verdict": "Bericht wordt bezorgd — blokkerend, na de spamcontrole; kan het bericht aanpassen en ziet het oordeel",
|
||||
"Message delivered (recommended) — fire after delivery, response ignored": "Bericht bezorgd (aanbevolen) — wordt geactiveerd na bezorging, antwoord wordt genegeerd",
|
||||
"Read the webhook documentation for all technical details": "Lees de webhookdocumentatie voor alle technische details",
|
||||
"Method": "Methode",
|
||||
"Cancel import and delete its messages": "Import annuleren en berichten verwijderen",
|
||||
"Cancelled": "Geannuleerd",
|
||||
"Check for new mail regularly": "Regelmatig controleren op nieuwe e-mail",
|
||||
"Checking every {{count}} min_one": "Controle elke {{count}} min",
|
||||
"Checking every {{count}} min_other": "Controle elke {{count}} min",
|
||||
"Delete imported messages": "Geïmporteerde berichten verwijderen",
|
||||
"Delete the messages of \"{{name}}\"": "Berichten van \"{{name}}\" verwijderen",
|
||||
"Error while loading imports": "Fout bij het laden van imports",
|
||||
"Failed": "Mislukt",
|
||||
"Failed to update import.": "Bijwerken van import mislukt.",
|
||||
"Failed to update integration.": "Bijwerken van integratie mislukt.",
|
||||
"Failed: {{count}} messages_one": "Mislukt: {{count}} bericht",
|
||||
"Failed: {{count}} messages_other": "Mislukt: {{count}} berichten",
|
||||
"High failure rate": "Hoog foutpercentage",
|
||||
"Import actions": "Importacties",
|
||||
"Import cancelled and messages deleted.": "Import geannuleerd en berichten verwijderd.",
|
||||
"Import in progress": "Import wordt uitgevoerd",
|
||||
"Import complete": "Import voltooid",
|
||||
"Import mail into this mailbox from an archive (PST, MBOX, EML) or an IMAP account, and track or cancel running imports.": "Importeer e-mail in deze mailbox vanuit een archief (PST, MBOX, EML) of een IMAP-account, en volg of annuleer lopende imports.",
|
||||
"Import removed from the list. Its messages were kept.": "Import uit de lijst verwijderd. De berichten zijn behouden.",
|
||||
"Imported: {{count}} of {{total}} messages_one": "Geïmporteerd: {{count}} van {{total}} berichten",
|
||||
"Imported: {{count}} of {{total}} messages_other": "Geïmporteerd: {{count}} van {{total}} berichten",
|
||||
"Imports": "Imports",
|
||||
"Integration \"{{name}}\" paused.": "Integratie \"{{name}}\" gepauzeerd.",
|
||||
"Integration \"{{name}}\" resumed.": "Integratie \"{{name}}\" hervat.",
|
||||
"Loading imports...": "Imports laden...",
|
||||
"New import": "Nieuwe import",
|
||||
"No imports yet": "Nog geen imports",
|
||||
"Pause integration": "Integratie pauzeren",
|
||||
"Pause polling": "Controle pauzeren",
|
||||
"Polling paused": "Controle gepauzeerd",
|
||||
"Polling paused.": "Controle gepauzeerd.",
|
||||
"Polling resumed.": "Controle hervat.",
|
||||
"Remove from list (keep messages)": "Uit de lijst verwijderen (berichten behouden)",
|
||||
"Resume integration": "Integratie hervatten",
|
||||
"Resume polling": "Controle hervatten",
|
||||
"Starting…": "Starten…",
|
||||
"Status": "Status",
|
||||
"The PST archive is unreadable: the file is corrupt or its internal structure is incomplete. Retrying will not help — please try to re-generate the archive.": "Het PST-archief is onleesbaar: het bestand is beschadigd of de interne structuur is onvolledig. Opnieuw proberen helpt niet — probeer het archief opnieuw te genereren.",
|
||||
"This account will now be checked for new mail regularly.": "Dit account wordt voortaan regelmatig gecontroleerd op nieuwe e-mail.",
|
||||
"This deletes every message this import created, except those in conversations with replies or other activity. This action is irreversible!": "Dit verwijdert elk bericht dat deze import heeft aangemaakt, behalve berichten in gesprekken met antwoorden of andere activiteit. Deze actie is onomkeerbaar!",
|
||||
"Unable to check the import status.": "Kan de importstatus niet controleren.",
|
||||
"Username is required.": "Gebruikersnaam is verplicht.",
|
||||
"You can safely retry the import — messages already imported will not be duplicated.": "U kunt de import veilig opnieuw proberen — reeds geïmporteerde berichten worden niet gedupliceerd.",
|
||||
"{{count}} failed_one": "{{count}} mislukt",
|
||||
"{{count}} failed_other": "{{count}} mislukt",
|
||||
"{{count}} import_one": "{{count}} import",
|
||||
"{{count}} import_other": "{{count}} imports",
|
||||
"{{count}} messages were imported before the error._one": "{{count}} bericht werd geïmporteerd vóór de fout.",
|
||||
"{{count}} messages were imported before the error._other": "{{count}} berichten werden geïmporteerd vóór de fout."
|
||||
"Your session has expired. Please log in again.": "Je sessie is verlopen. Log opnieuw in."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/* Web Push service worker.
|
||||
*
|
||||
* Registered by the app (see features/.../devices-view/web-push.ts) when the
|
||||
* user enables notifications in a browser.
|
||||
*
|
||||
* The push payload is deliberately thin and content-free — only routing ids +
|
||||
* the unread count. To show a Gmail-style banner (sender + subject) WITHOUT
|
||||
* ever putting content on the push transport, we fetch the message over the
|
||||
* user's own authenticated session here and rewrite the notification
|
||||
* ("fetch-to-enrich"). If that fetch fails (offline, cross-origin API, signed
|
||||
* out) we fall back to a generic content-free banner.
|
||||
*
|
||||
* `userVisibleOnly: true` subscriptions must surface a notification for every
|
||||
* push — with one exception the browsers grant precisely because the user
|
||||
* already sees the news: a focused app window (see handlePush).
|
||||
*/
|
||||
/* global self, clients, fetch */
|
||||
|
||||
// Take over as soon as a new version installs instead of sitting in the
|
||||
// "waiting" state until every controlled tab closes. This SW does no offline
|
||||
// fetch caching, so there is no stale-cache hazard in activating early — it just
|
||||
// means a pushed sw.js change (e.g. a new `?api=` origin or enrichment fix)
|
||||
// starts serving on the next load rather than days later.
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", (event) =>
|
||||
event.waitUntil(self.clients.claim()),
|
||||
);
|
||||
|
||||
// API origin, carried in the registration URL's query by the app (web-push.ts)
|
||||
// because this static file can't read the build env. It lets enrichment reach
|
||||
// the backend even when the API is on a different origin than the app (dev:
|
||||
// front :8900 / API :8901). Empty string ⇒ same-origin, matching the previous
|
||||
// behaviour and covering an older registration that predates the `?api=` param.
|
||||
const API_ORIGIN =
|
||||
new URLSearchParams(self.location.search).get("api") || "";
|
||||
const MESSAGE_URL = (id) =>
|
||||
`${API_ORIGIN}/api/v1.0/messages/${encodeURIComponent(id)}/`;
|
||||
|
||||
// Current server VAPID public key, carried in the registration URL's query by
|
||||
// the app (web-push.ts) for the same reason as `?api=` — a static file can't
|
||||
// read the build env. Used by `pushsubscriptionchange` to re-subscribe with the
|
||||
// *current* key rather than the one on the (possibly rotated-away) old
|
||||
// subscription. Empty for a registration that predates the `?vapid=` param.
|
||||
const VAPID_PUBLIC_KEY =
|
||||
new URLSearchParams(self.location.search).get("vapid") || "";
|
||||
|
||||
// UI language, carried in the registration URL's query like `?api=` — a static
|
||||
// file can't reach i18next, and only the generic-banner fallback below needs
|
||||
// translating (an enriched banner shows the real sender/subject). The pair
|
||||
// mirrors the "New message" key in public/locales (FR + EN, the two supported
|
||||
// languages); the on-load re-registration keeps the param in step with the
|
||||
// user's language. Empty (a registration predating `?lang=`) ⇒ English.
|
||||
const LANG = new URLSearchParams(self.location.search).get("lang") || "en";
|
||||
const GENERIC_TITLE = /^fr\b/i.test(LANG) ? "Nouveau message" : "New message";
|
||||
|
||||
// Decode a base64url VAPID key into the Uint8Array subscribe() expects. Mirrors
|
||||
// urlBase64ToUint8Array in web-push.ts.
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(base64);
|
||||
const output = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
output[i] = raw.charCodeAt(i);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
const senderLabel = (message) => {
|
||||
const s = message && message.sender;
|
||||
return (s && (s.name || s.email)) || GENERIC_TITLE;
|
||||
};
|
||||
|
||||
async function buildNotification(payload) {
|
||||
// Default: content-free banner (what the thin payload alone can show).
|
||||
let title = GENERIC_TITLE;
|
||||
const tag = payload.thread_id ? "thread-" + payload.thread_id : undefined;
|
||||
const options = {
|
||||
body: "",
|
||||
// App icon shown on the banner. Served from the app origin (this SW's
|
||||
// scope), not API_ORIGIN, so it resolves same-origin. Without it the
|
||||
// platform falls back to the favicon/PWA icon, which is inconsistent
|
||||
// across browsers.
|
||||
icon: "/assets/icons/icon-192.webp",
|
||||
// Monochrome silhouette Android paints into the status bar when the full
|
||||
// icon doesn't fit. Only the alpha channel is used (Android tints the
|
||||
// shape), so this must stay a transparent monochrome PNG — not a color
|
||||
// .webp. Ignored on desktop/Firefox/Safari.
|
||||
badge: "/assets/icons/icon-mono-72.png",
|
||||
// Coalesce a burst in one thread into a single notification...
|
||||
tag,
|
||||
// ...but still re-alert (sound/vibrate) for each new message in that thread,
|
||||
// rather than silently swapping the banner. renotify requires a tag.
|
||||
renotify: Boolean(tag),
|
||||
data: payload,
|
||||
};
|
||||
|
||||
// Fetch-to-enrich: pull sender + subject over the authenticated session.
|
||||
if (payload.message_id) {
|
||||
try {
|
||||
const resp = await fetch(MESSAGE_URL(payload.message_id), {
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
// A hung network (captive portal) must not stall showNotification past
|
||||
// the OS's patience — Chrome would show its own generic "site updated
|
||||
// in the background" instead. The catch keeps the generic banner.
|
||||
signal: AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
|
||||
});
|
||||
if (resp.ok) {
|
||||
const message = await resp.json();
|
||||
title = senderLabel(message);
|
||||
options.body = message.subject || "";
|
||||
}
|
||||
} catch {
|
||||
// Offline / cross-origin / signed out — keep the generic banner.
|
||||
}
|
||||
}
|
||||
|
||||
// Drive the installed-PWA app badge from the unread count the payload carries.
|
||||
// Guarded: Firefox/Safari (and non-installed contexts) lack the Badging API.
|
||||
if ("setAppBadge" in self.navigator && typeof payload.unread_count === "number") {
|
||||
self.navigator.setAppBadge(payload.unread_count).catch(() => {});
|
||||
}
|
||||
|
||||
return self.registration.showNotification(title, options);
|
||||
}
|
||||
|
||||
// Message type the worker posts to the app on every push it handles. An open
|
||||
// tab sitting in the background has no other way to learn that mail arrived
|
||||
// before its next mailbox poll; it uses this to raise the favicon badge at once
|
||||
// (see features/providers/use-unread-badge.ts). Content-free by design — the
|
||||
// app re-reads its own state, the message is only a "go look now" nudge.
|
||||
const PUSH_RECEIVED = "push-received";
|
||||
|
||||
// Which mailbox a window is displaying, read from its URL: every mailbox view
|
||||
// lives under /mailbox/{mailboxId}[/...] (src/routes/mailbox/$mailboxId/).
|
||||
// Null for any other page — including a stale URL the router hasn't pushed
|
||||
// yet, which errs on the side of showing the banner.
|
||||
const viewedMailboxId = (client) => {
|
||||
try {
|
||||
const match = new URL(client.url).pathname.match(/^\/mailbox\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
async function handlePush(payload) {
|
||||
// Best-effort: a failed lookup must not swallow the banner, so fall through
|
||||
// with an empty list (no client to nudge, none focused ⇒ notify).
|
||||
let windowClients = [];
|
||||
try {
|
||||
windowClients = await clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
} catch {
|
||||
// Keep going: the banner matters more than the nudge.
|
||||
}
|
||||
|
||||
for (const client of windowClients) {
|
||||
client.postMessage({ type: PUSH_RECEIVED });
|
||||
}
|
||||
|
||||
// The user is looking at the app right now *on the mailbox the message
|
||||
// landed in* (per the focused tab's URL, see viewedMailboxId): the mail
|
||||
// lands in the list on its own (30s mailbox poll, see
|
||||
// features/providers/mailbox.tsx — it only refreshes the mailbox being
|
||||
// viewed), so a banner would announce what is already on its way onto their
|
||||
// screen — and an app badge would contradict the clear-on-foreground rule
|
||||
// the rest of the app follows. This is the one case `userVisibleOnly`
|
||||
// tolerates a silent push: browsers only substitute their own "site updated
|
||||
// in the background" notice when no window of the origin is visible, and a
|
||||
// focused window is visible. Mirrors the native side, where iOS foreground
|
||||
// presentation is limited to the badge (capacitor.config.ts) and Android in
|
||||
// foreground never auto-displays. A focused window on *another* mailbox (or
|
||||
// any page outside /mailbox/) still gets the banner: nothing on screen
|
||||
// would surface the message otherwise. A payload without mailbox_id (older
|
||||
// backend) can't be compared, so it keeps the previous suppress-on-focus
|
||||
// behaviour.
|
||||
const focusedClient = windowClients.find((client) => client.focused);
|
||||
if (focusedClient) {
|
||||
if (!payload.mailbox_id) {
|
||||
return;
|
||||
}
|
||||
if (viewedMailboxId(focusedClient) === payload.mailbox_id) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return buildNotification(payload);
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
event.waitUntil(handlePush(payload));
|
||||
});
|
||||
|
||||
// Deep-link target from the routing ids the payload carries. mailbox_id is the
|
||||
// recipient's mailbox the thread is read in (added to the thin payload precisely
|
||||
// so we can route here); without it we can only open the app root. `has_active`
|
||||
// puts the thread list behind the tap on the inbox filter.
|
||||
//
|
||||
// The message goes in the *hash*, not a query param: the thread view scrolls to
|
||||
// (and highlights) `#thread-message-{id}` on mount, whereas an unknown query key
|
||||
// is dropped by THREADS_LIST_NUMERIC_FILTERS' allow-list — so `?message_id=`
|
||||
// landed on the thread without ever reaching the message.
|
||||
// Mirrored by pushTargetUrl in features/native/push.ts — keep the two in sync.
|
||||
const targetUrl = (payload) => {
|
||||
if (!payload || !payload.mailbox_id || !payload.thread_id) {
|
||||
return "/";
|
||||
}
|
||||
const url = new URL(
|
||||
`/mailbox/${payload.mailbox_id}/thread/${payload.thread_id}`,
|
||||
self.location.origin,
|
||||
);
|
||||
url.searchParams.set("has_active", "1");
|
||||
if (payload.message_id) {
|
||||
url.hash = `thread-message-${payload.message_id}`;
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = targetUrl(event.notification.data);
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// Arriving in the app from a notification: drop the badge. The push
|
||||
// handler is the only writer that raises it, so clearing on tap keeps it
|
||||
// from lingering once the user has acknowledged the alert. The app also
|
||||
// clears it on foreground (see features/auth) for icon-launch opens.
|
||||
// Guarded + best-effort — Badging API absent on Firefox/Safari.
|
||||
if ("clearAppBadge" in self.navigator) {
|
||||
self.navigator.clearAppBadge().catch(() => {});
|
||||
}
|
||||
|
||||
const windowClients = await clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
|
||||
// Reuse an existing app tab: prefer the focused one, else the first
|
||||
// window matchAll returns (all are same-origin app windows). The previous
|
||||
// loop returned on the first tab regardless of focus.
|
||||
const client = windowClients.find((c) => c.focused) || windowClients[0];
|
||||
|
||||
// Open a fresh window at the deep-link. Also the fallback when navigate()
|
||||
// can't run, so it lives in one place.
|
||||
const openFresh = () =>
|
||||
clients.openWindow ? clients.openWindow(url) : undefined;
|
||||
|
||||
if (!client || !("focus" in client)) {
|
||||
return openFresh();
|
||||
}
|
||||
|
||||
// Bring the tab forward first, so a tap always surfaces the app even when
|
||||
// the follow-up navigate() can't run (root url, or a rejection below).
|
||||
const focused = (await client.focus()) || client;
|
||||
|
||||
if (url === "/" || !("navigate" in focused)) {
|
||||
return focused;
|
||||
}
|
||||
|
||||
// navigate() only works on a client *controlled* by this SW; on an
|
||||
// uncontrolled tab (e.g. one hard-reloaded past the SW, which
|
||||
// includeUncontrolled still surfaces) it rejects with a TypeError. Without
|
||||
// this catch the tap would silently drop the deep-link — fall back to
|
||||
// opening a fresh, controlled window instead.
|
||||
try {
|
||||
return await focused.navigate(url);
|
||||
} catch {
|
||||
return openFresh();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
// Message type the worker posts to the app when it re-subscribes; the app
|
||||
// listens for it and registers the new endpoint through its CSRF-correct client.
|
||||
const PUSH_SUBSCRIPTION_CHANGED = "push-subscription-changed";
|
||||
|
||||
// The browser can rotate or expire our push subscription at any time (clearing
|
||||
// site data, periodic rotation, key changes). Without this the user silently
|
||||
// stops receiving pushes until they revisit settings. Re-subscribe with the
|
||||
// same VAPID key (carried on the old subscription); registering the new endpoint
|
||||
// on the backend is an authenticated, CSRF-protected POST that the worker can no
|
||||
// longer sign itself — under CSRF_USE_SESSIONS the token is not a readable
|
||||
// cookie but lives in the app page's memory (delivered via /users/me/). So we
|
||||
// hand the new subscription to any open client, which re-registers it through
|
||||
// the app's API client; if no client is open, the subscription persists locally
|
||||
// and refreshWebPushSubscription re-registers it on the next app load.
|
||||
self.addEventListener("pushsubscriptionchange", (event) => {
|
||||
// Prefer the current server key (injected in this SW's script URL) over the
|
||||
// one on the old subscription: on a key rotation the old key is exactly what
|
||||
// the push service now rejects, so re-subscribing with it would recreate a
|
||||
// dead subscription. Fall back to the old key for registrations predating the
|
||||
// `?vapid=` param.
|
||||
const applicationServerKey = VAPID_PUBLIC_KEY
|
||||
? urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
||||
: event.oldSubscription &&
|
||||
event.oldSubscription.options &&
|
||||
event.oldSubscription.options.applicationServerKey;
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const subscription = await self.registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey,
|
||||
});
|
||||
const json = subscription.toJSON();
|
||||
const p256dh = json.keys && json.keys.p256dh;
|
||||
const auth = json.keys && json.keys.auth;
|
||||
if (!json.endpoint || !p256dh || !auth) {
|
||||
return;
|
||||
}
|
||||
const windowClients = await clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
for (const client of windowClients) {
|
||||
client.postMessage({
|
||||
type: PUSH_SUBSCRIPTION_CHANGED,
|
||||
subscription: { endpoint: json.endpoint, keys: { p256dh, auth } },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort self-heal; the app's on-load refresh is the fallback.
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { installThemeFavicons } from "@/features/providers/theme-favicons";
|
||||
import { initSentry } from "@/features/sentry";
|
||||
import { handle } from '@/features/utils/errors';
|
||||
import { checkAndApplyOtaUpdate, notifyOtaAppReady } from "./features/native/ota";
|
||||
import { listenForNativePushTaps } from "./features/native/push";
|
||||
|
||||
// Tag the document on the Capacitor native app so the stylesheet can opt into
|
||||
// mobile-only chrome (compact header, floating bottom bars) without each
|
||||
@@ -47,6 +48,11 @@ declare module "@tanstack/react-router" {
|
||||
}
|
||||
}
|
||||
|
||||
// Notification taps deep-link into the thread the push points at, through the
|
||||
// router history (an in-app navigation, not a WebView reload). Registered
|
||||
// before any await so the tap that cold-started the app is not missed.
|
||||
listenForNativePushTaps((url) => router.history.push(url));
|
||||
|
||||
/**
|
||||
* Fetch the backend configuration then initialize everything that must be
|
||||
* ready before the first React render: Sentry, i18n and the theme favicons.
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
ChannelRequest,
|
||||
PatchedChannelRequest,
|
||||
RegeneratedSecretResponse,
|
||||
UserChannelCreateRequestRequest,
|
||||
} from ".././models";
|
||||
|
||||
import { fetchAPI } from "../../fetch-api";
|
||||
@@ -1203,42 +1204,31 @@ export function useUsersMeChannelsList<
|
||||
/**
|
||||
* Manage personal (scope_level=user) integration channels
|
||||
*/
|
||||
export type usersMeChannelsCreateResponse200 = {
|
||||
data: Channel;
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type usersMeChannelsCreateResponse201 = {
|
||||
data: ChannelCreateResponse;
|
||||
status: 201;
|
||||
};
|
||||
|
||||
export type usersMeChannelsCreateResponse400 = {
|
||||
data: void;
|
||||
status: 400;
|
||||
};
|
||||
|
||||
export type usersMeChannelsCreateResponse403 = {
|
||||
data: void;
|
||||
status: 403;
|
||||
};
|
||||
|
||||
export type usersMeChannelsCreateResponseSuccess =
|
||||
usersMeChannelsCreateResponse201 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type usersMeChannelsCreateResponseError = (
|
||||
| usersMeChannelsCreateResponse400
|
||||
| usersMeChannelsCreateResponse403
|
||||
export type usersMeChannelsCreateResponseSuccess = (
|
||||
| usersMeChannelsCreateResponse200
|
||||
| usersMeChannelsCreateResponse201
|
||||
) & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type usersMeChannelsCreateResponse =
|
||||
| usersMeChannelsCreateResponseSuccess
|
||||
| usersMeChannelsCreateResponseError;
|
||||
usersMeChannelsCreateResponseSuccess;
|
||||
|
||||
export const getUsersMeChannelsCreateUrl = () => {
|
||||
return `/api/v1.0/users/me/channels/`;
|
||||
};
|
||||
|
||||
export const usersMeChannelsCreate = async (
|
||||
channelRequest: ChannelRequest,
|
||||
userChannelCreateRequestRequest: UserChannelCreateRequestRequest,
|
||||
options?: RequestInit,
|
||||
): Promise<usersMeChannelsCreateResponse> => {
|
||||
return fetchAPI<usersMeChannelsCreateResponse>(
|
||||
@@ -1247,26 +1237,26 @@ export const usersMeChannelsCreate = async (
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(channelRequest),
|
||||
body: JSON.stringify(userChannelCreateRequestRequest),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getUsersMeChannelsCreateMutationOptions = <
|
||||
TError = ErrorType<void>,
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof usersMeChannelsCreate>>,
|
||||
TError,
|
||||
{ data: ChannelRequest },
|
||||
{ data: UserChannelCreateRequestRequest },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof fetchAPI>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof usersMeChannelsCreate>>,
|
||||
TError,
|
||||
{ data: ChannelRequest },
|
||||
{ data: UserChannelCreateRequestRequest },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["usersMeChannelsCreate"];
|
||||
@@ -1280,7 +1270,7 @@ export const getUsersMeChannelsCreateMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof usersMeChannelsCreate>>,
|
||||
{ data: ChannelRequest }
|
||||
{ data: UserChannelCreateRequestRequest }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -1293,18 +1283,18 @@ export const getUsersMeChannelsCreateMutationOptions = <
|
||||
export type UsersMeChannelsCreateMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof usersMeChannelsCreate>>
|
||||
>;
|
||||
export type UsersMeChannelsCreateMutationBody = ChannelRequest;
|
||||
export type UsersMeChannelsCreateMutationError = ErrorType<void>;
|
||||
export type UsersMeChannelsCreateMutationBody = UserChannelCreateRequestRequest;
|
||||
export type UsersMeChannelsCreateMutationError = ErrorType<unknown>;
|
||||
|
||||
export const useUsersMeChannelsCreate = <
|
||||
TError = ErrorType<void>,
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(
|
||||
options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof usersMeChannelsCreate>>,
|
||||
TError,
|
||||
{ data: ChannelRequest },
|
||||
{ data: UserChannelCreateRequestRequest },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof fetchAPI>;
|
||||
@@ -1313,7 +1303,7 @@ export const useUsersMeChannelsCreate = <
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof usersMeChannelsCreate>>,
|
||||
TError,
|
||||
{ data: ChannelRequest },
|
||||
{ data: UserChannelCreateRequestRequest },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUsersMeChannelsCreateMutationOptions(options);
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface Channel {
|
||||
readonly user: string | null;
|
||||
/** @nullable */
|
||||
readonly last_used_at: string | null;
|
||||
/** @nullable */
|
||||
readonly token_hash: string | null;
|
||||
/** date and time at which a record was created */
|
||||
readonly created_at: string;
|
||||
/** date and time at which a record was last updated */
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface ChannelCreateResponse {
|
||||
readonly user: string | null;
|
||||
/** @nullable */
|
||||
readonly last_used_at: string | null;
|
||||
/** @nullable */
|
||||
readonly token_hash: string | null;
|
||||
/** date and time at which a record was created */
|
||||
readonly created_at: string;
|
||||
/** date and time at which a record was last updated */
|
||||
|
||||
@@ -61,4 +61,8 @@ export type ConfigRetrieve200 = {
|
||||
readonly FRONTEND_LAGAUFRE_WIDGET_CONFIG?: ConfigRetrieve200FRONTENDLAGAUFREWIDGETCONFIG;
|
||||
/** OTA channel manifest URL the mobile apps poll at startup; unset disables OTA updates */
|
||||
readonly MOBILE_OTA_MANIFEST_URL?: string;
|
||||
/** Whether push notifications are available on this deployment (gates the device-registration UI). */
|
||||
readonly PUSH_ENABLED: boolean;
|
||||
/** VAPID public key (base64url) the web client passes as applicationServerKey to subscribe; null when Web Push is not configured. */
|
||||
readonly PUSH_VAPID_PUBLIC_KEY?: string;
|
||||
};
|
||||
|
||||
@@ -150,6 +150,9 @@ export * from "./patched_thread_access_request";
|
||||
export * from "./patched_thread_event_request";
|
||||
export * from "./placeholders_retrieve200";
|
||||
export * from "./placeholders_retrieve200_i18n";
|
||||
export * from "./platform_enum";
|
||||
export * from "./push_channel_create_request";
|
||||
export * from "./push_channel_create_type_enum";
|
||||
export * from "./read_message_template";
|
||||
export * from "./read_message_template_metadata";
|
||||
export * from "./regenerated_secret_response";
|
||||
@@ -209,9 +212,11 @@ export * from "./threads_stats_retrieve_params";
|
||||
export * from "./threads_stats_retrieve_stats_fields";
|
||||
export * from "./threads_summary_retrieve200";
|
||||
export * from "./tree_label";
|
||||
export * from "./user_channel_create_request_request";
|
||||
export * from "./user_with_abilities";
|
||||
export * from "./user_with_abilities_abilities";
|
||||
export * from "./user_with_abilities_custom_attributes";
|
||||
export * from "./user_without_abilities";
|
||||
export * from "./user_without_abilities_custom_attributes";
|
||||
export * from "./users_list_params";
|
||||
export * from "./web_push_keys_request";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Generated by orval 🍺
|
||||
* Do not edit manually.
|
||||
* messages API
|
||||
* This is the messages API schema.
|
||||
* OpenAPI spec version: 1.0.0 (v1.0)
|
||||
*/
|
||||
|
||||
/**
|
||||
* * `apns` - Apple (APNs)
|
||||
* `fcm` - Google (FCM)
|
||||
* `web` - Web Push
|
||||
*/
|
||||
export type PlatformEnum = (typeof PlatformEnum)[keyof typeof PlatformEnum];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const PlatformEnum = {
|
||||
apns: "apns",
|
||||
fcm: "fcm",
|
||||
web: "web",
|
||||
} as const;
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Generated by orval 🍺
|
||||
* Do not edit manually.
|
||||
* messages API
|
||||
* This is the messages API schema.
|
||||
* OpenAPI spec version: 1.0.0 (v1.0)
|
||||
*/
|
||||
import type { PlatformEnum } from "./platform_enum";
|
||||
import type { WebPushKeysRequest } from "./web_push_keys_request";
|
||||
import type { PushChannelCreateTypeEnum } from "./push_channel_create_type_enum";
|
||||
|
||||
/**
|
||||
* Schema variant of the push registration body for ``POST .../channels/``.
|
||||
|
||||
Identical to ``PushDeviceRegistrationSerializer`` plus the ``type``
|
||||
discriminator, so the polymorphic create endpoint documents the push shape
|
||||
({type:"push", platform, token, keys?, name?, app_version?}) alongside the
|
||||
generic channel shape. Validation at runtime still uses the parent.
|
||||
*/
|
||||
export interface PushChannelCreateRequest {
|
||||
platform: PlatformEnum;
|
||||
/**
|
||||
* @minLength 1
|
||||
* @maxLength 8192
|
||||
*/
|
||||
token: string;
|
||||
/** @maxLength 64 */
|
||||
app_version?: string;
|
||||
keys?: WebPushKeysRequest;
|
||||
/** @maxLength 255 */
|
||||
name?: string;
|
||||
type: PushChannelCreateTypeEnum;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Generated by orval 🍺
|
||||
* Do not edit manually.
|
||||
* messages API
|
||||
* This is the messages API schema.
|
||||
* OpenAPI spec version: 1.0.0 (v1.0)
|
||||
*/
|
||||
|
||||
/**
|
||||
* * `push` - push
|
||||
*/
|
||||
export type PushChannelCreateTypeEnum =
|
||||
(typeof PushChannelCreateTypeEnum)[keyof typeof PushChannelCreateTypeEnum];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const PushChannelCreateTypeEnum = {
|
||||
push: "push",
|
||||
} as const;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generated by orval 🍺
|
||||
* Do not edit manually.
|
||||
* messages API
|
||||
* This is the messages API schema.
|
||||
* OpenAPI spec version: 1.0.0 (v1.0)
|
||||
*/
|
||||
import type { ChannelRequest } from "./channel_request";
|
||||
import type { PushChannelCreateRequest } from "./push_channel_create_request";
|
||||
|
||||
export type UserChannelCreateRequestRequest =
|
||||
| ChannelRequest
|
||||
| PushChannelCreateRequest;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generated by orval 🍺
|
||||
* Do not edit manually.
|
||||
* messages API
|
||||
* This is the messages API schema.
|
||||
* OpenAPI spec version: 1.0.0 (v1.0)
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Web Push subscription key pair (``p256dh`` and ``auth``).
|
||||
*/
|
||||
export interface WebPushKeysRequest {
|
||||
/** @minLength 1 */
|
||||
p256dh: string;
|
||||
/** @minLength 1 */
|
||||
auth: string;
|
||||
}
|
||||
@@ -10,9 +10,29 @@ import { useTranslation } from "react-i18next";
|
||||
import { SESSION_EXPIRED_KEY } from "../config/constants";
|
||||
import { nativeLogin, nativeLogout } from "../native/auth";
|
||||
import { isNativePlatform } from "../native/platform";
|
||||
import {
|
||||
clearDeliveredNativeNotifications,
|
||||
refreshNativePushRegistration,
|
||||
} from "../native/push";
|
||||
import { useConfig } from "../providers/config";
|
||||
import {
|
||||
listenForPushSubscriptionChange,
|
||||
refreshWebPushSubscription,
|
||||
} from "../layouts/components/mailbox-settings/devices-view/web-push";
|
||||
import { attemptSilentLogin, canAttemptSilentLogin } from "./silent-login";
|
||||
|
||||
/**
|
||||
* Log the user out.
|
||||
*
|
||||
* Web push is deliberately NOT torn down here. A voluntary logout is handled
|
||||
* *server-side*: the ``user_logged_out`` receiver deletes the push channel
|
||||
* stamped with this session, so the device stops receiving the moment the
|
||||
* logout view runs — browser subscription and per-user opt-in marker survive,
|
||||
* which is what lets the same user's notifications resume transparently on
|
||||
* their next login (`refreshWebPushSubscription`). A session that merely
|
||||
* expires (401 funnel) reaches the logout view anonymous, so nothing is
|
||||
* unregistered and notifications keep flowing — by design.
|
||||
*/
|
||||
export const logout = () => {
|
||||
if (isNativePlatform()) {
|
||||
void nativeLogout();
|
||||
@@ -70,7 +90,9 @@ export const Auth = ({
|
||||
user === null &&
|
||||
canAttemptSilentLogin(),
|
||||
[config.FRONTEND_SILENT_LOGIN_ENABLED, user]
|
||||
);
|
||||
);
|
||||
const isAuthenticated = !!user;
|
||||
const userId = user?.id;
|
||||
|
||||
// Cache the session-bound CSRF token delivered with /users/me/ so mutations
|
||||
// can echo it in the X-CSRFToken header (no `csrftoken` cookie any more under
|
||||
@@ -81,6 +103,53 @@ export const Auth = ({
|
||||
if (user) setWebCsrfToken(user.csrf_token);
|
||||
}, [user]);
|
||||
|
||||
// Self-heal push once authenticated: if the user previously enabled it on
|
||||
// this device, re-register the current subscription/token so a rotated one
|
||||
// doesn't silently stop delivering. Passive — no-ops unless the user opted
|
||||
// in here. On the web the listener additionally catches a rotation that
|
||||
// happens while the app stays open: the worker can't sign the registration
|
||||
// POST, so it hands the new subscription to us. The native shells have no
|
||||
// equivalent — the on-launch refresh is their rotation catch-up.
|
||||
//
|
||||
// This runs here, gated on an authenticated `user`, rather than in
|
||||
// ConfigProvider: the registration POST needs the in-memory CSRF token, which
|
||||
// is only set once `/users/me/` resolves (same `user` that drives
|
||||
// `setWebCsrfToken` above). Firing it from the config layer raced ahead of the
|
||||
// token and the POST 403'd (no more `csrftoken` cookie under CSRF_USE_SESSIONS).
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !config.PUSH_ENABLED) return;
|
||||
if (isNativePlatform()) {
|
||||
refreshNativePushRegistration(userId);
|
||||
return;
|
||||
}
|
||||
if (!config.PUSH_VAPID_PUBLIC_KEY) return;
|
||||
refreshWebPushSubscription(config.PUSH_VAPID_PUBLIC_KEY, userId);
|
||||
return listenForPushSubscriptionChange();
|
||||
}, [isAuthenticated, userId, config.PUSH_ENABLED, config.PUSH_VAPID_PUBLIC_KEY]);
|
||||
|
||||
// Clear the installed-PWA badge whenever the app is in the foreground. The
|
||||
// service worker's push handler is the only thing that raises it (and clears
|
||||
// it on notification tap); this covers the icon-launch / tab-refocus paths
|
||||
// where the user reaches the app without going through a notification, so a
|
||||
// badge never lingers while they are actually looking at their mail. Runs
|
||||
// once on mount (visible load) and on every hidden→visible transition.
|
||||
// Best-effort no-op where the Badging API is unavailable (Firefox/Safari).
|
||||
// The native shells dismiss their delivered OS notifications on the same
|
||||
// signal (the iOS badge itself is reset in the AppDelegate).
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
const clearBadge = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
clearDeliveredNativeNotifications();
|
||||
if ("clearAppBadge" in navigator) {
|
||||
navigator.clearAppBadge().catch(() => {});
|
||||
}
|
||||
};
|
||||
clearBadge();
|
||||
document.addEventListener("visibilitychange", clearBadge);
|
||||
return () => document.removeEventListener("visibilitychange", clearBadge);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user !== null) return;
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ export type AppConfig = Omit<
|
||||
| "DRIVE"
|
||||
| "LANGUAGES"
|
||||
| "LANGUAGE_CODE"
|
||||
| "SENTRY_DSN"
|
||||
| "FRONTEND_THEME_CONFIG"
|
||||
| "FRONTEND_FORCED_DEFAULT_LANGUAGE"
|
||||
| "FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB"
|
||||
@@ -47,7 +46,6 @@ export type AppConfig = Omit<
|
||||
BASE_LANGUAGE: string;
|
||||
/** When true, fall back to BASE_LANGUAGE instead of the browser language. */
|
||||
IS_LANGUAGE_FORCED: boolean;
|
||||
SENTRY_DSN?: string;
|
||||
SENTRY_ENVIRONMENT?: string;
|
||||
THEME_CONFIG: ThemeConfig;
|
||||
MULTIPART_UPLOAD_CHUNK_SIZE_MB: number;
|
||||
@@ -309,5 +307,7 @@ export const resolveConfig = (api?: ConfigRetrieve200): AppConfig => {
|
||||
),
|
||||
FEEDBACK_WIDGET: resolveFeedbackWidget(api),
|
||||
LAGAUFRE_WIDGET: resolveLagaufreWidget(api),
|
||||
PUSH_ENABLED: api?.PUSH_ENABLED ?? false,
|
||||
PUSH_VAPID_PUBLIC_KEY: api?.PUSH_VAPID_PUBLIC_KEY,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ControlledMailboxSettings } from "@/features/controlled-modals/mailbox-settings";
|
||||
import { MODAL_MAILBOX_SETTINGS_ID } from "@/features/layouts/components/mailbox-settings/modal-mailbox-settings";
|
||||
import { ControlledNotifications } from "@/features/controlled-modals/notifications";
|
||||
import { MODAL_NOTIFICATIONS_ID } from "@/features/layouts/components/notifications-settings/modal-notifications";
|
||||
import { registerModal } from "../providers/modal-store";
|
||||
|
||||
// Imperatively register all controlled modals. (The message importer isn't one:
|
||||
// it lives inside the mailbox settings modal's Imports tab — see useOpenImporter.)
|
||||
registerModal(MODAL_MAILBOX_SETTINGS_ID, ControlledMailboxSettings);
|
||||
registerModal(MODAL_NOTIFICATIONS_ID, ControlledNotifications);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
MODAL_NOTIFICATIONS_ID,
|
||||
ModalNotifications,
|
||||
} from "@/features/layouts/components/notifications-settings/modal-notifications";
|
||||
import { useModalStore } from "@/features/providers/modal-store";
|
||||
|
||||
/**
|
||||
* Binds the account-level notifications modal to the global modal store. Kept
|
||||
* separate from the modal component on purpose: the component must never import
|
||||
* the store, as that back-edge would close an import cycle (modal-store →
|
||||
* controlled-modals → modal → store) and trip a temporal-dead-zone error on the
|
||||
* modal id at registration time.
|
||||
*/
|
||||
export const ControlledNotifications = () => {
|
||||
const { isModalOpen, closeModal } = useModalStore();
|
||||
|
||||
return (
|
||||
<ModalNotifications
|
||||
isOpen={isModalOpen(MODAL_NOTIFICATIONS_ID)}
|
||||
onClose={() => closeModal(MODAL_NOTIFICATIONS_ID)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { Link } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminMailDomainProvider, useAdminMailDomain } from "@/features/providers/admin-maildomain";
|
||||
import useAbility, { Abilities } from "@/hooks/use-ability";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { ErrorPage } from "@/features/ui/components/error-page";
|
||||
import { Toaster } from "@/features/ui/components/toaster";
|
||||
import { Badge, Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit";
|
||||
@@ -25,6 +26,19 @@ function AdminLayoutContent({
|
||||
const { t } = useTranslation();
|
||||
const { selectedMailDomain } = useAdminMailDomain();
|
||||
const canViewDomainAdmin = useAbility(Abilities.CAN_VIEW_DOMAIN_ADMIN);
|
||||
const tabLabels: Record<string, string> = {
|
||||
addresses: t("Addresses"),
|
||||
dns: t("DNS"),
|
||||
signatures: t("Signatures"),
|
||||
};
|
||||
const domainName = selectedMailDomain?.name || selectedMailDomain?.id;
|
||||
|
||||
// The domain only exists inside `AdminMailDomainProvider`, so the title of
|
||||
// every admin route is set here rather than in the routes themselves.
|
||||
useDocumentTitle(
|
||||
domainName ?? t("Maildomains management"),
|
||||
domainName && currentTab && tabLabels[currentTab]
|
||||
);
|
||||
|
||||
// Build breadcrumb items
|
||||
const breadcrumbItems = [
|
||||
@@ -57,14 +71,10 @@ function AdminLayoutContent({
|
||||
|
||||
// Add current page to breadcrumbs if not on main addresses page
|
||||
if (currentTab && currentTab !== "addresses") {
|
||||
const tabLabels = {
|
||||
dns: t("DNS"),
|
||||
signatures: t("Signatures")
|
||||
};
|
||||
breadcrumbItems.push({
|
||||
content: (
|
||||
<span className="c__breadcrumbs__button active">
|
||||
{tabLabels[currentTab as keyof typeof tabLabels]}
|
||||
{tabLabels[currentTab]}
|
||||
</span>
|
||||
)
|
||||
});
|
||||
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
import { Icon, IconSize, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
|
||||
import { Trash } from "@gouvfr-lasuite/ui-kit/icons";
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
DataGrid,
|
||||
Input,
|
||||
Modal,
|
||||
ModalSize,
|
||||
Tooltip,
|
||||
useModals,
|
||||
} from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Channel,
|
||||
useUsersMeChannelsList,
|
||||
useUsersMeChannelsDestroy,
|
||||
useUsersMeChannelsPartialUpdate,
|
||||
getUsersMeChannelsListQueryKey,
|
||||
} from "@/features/api/gen";
|
||||
import { useConfig } from "@/features/providers/config";
|
||||
import { useAuth } from "@/features/auth";
|
||||
import {
|
||||
currentNativeTokenHash,
|
||||
enableNativePush,
|
||||
unregisterIfCurrentDevice,
|
||||
} from "@/features/native/push";
|
||||
import { isNativePlatform } from "@/features/native/platform";
|
||||
import { Banner } from "@/features/ui/components/banner";
|
||||
import { addToast, ToasterItem } from "@/features/ui/components/toaster";
|
||||
import { handle } from "@/features/utils/errors";
|
||||
import {
|
||||
currentWebPushTokenHash,
|
||||
enableWebPush,
|
||||
isWebPushSupported,
|
||||
unsubscribeIfCurrentBrowser,
|
||||
} from "./web-push";
|
||||
|
||||
// Push channels store their transport in ``settings.platform`` (apns/fcm/web).
|
||||
// The OS-friendly label lives here in the frontend — the backend deliberately
|
||||
// keys on transport, not OS (see core.enums.PushPlatformChoices).
|
||||
const getPlatformLabel = (
|
||||
platform: string | undefined,
|
||||
t: (key: string) => string,
|
||||
) => {
|
||||
switch (platform) {
|
||||
case "apns":
|
||||
return t("Apple (iPhone / iPad)");
|
||||
case "fcm":
|
||||
return t("Android");
|
||||
case "web":
|
||||
return t("Web browser");
|
||||
default:
|
||||
return t("Device");
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platform: string | undefined) => {
|
||||
switch (platform) {
|
||||
case "apns":
|
||||
return "phone_iphone";
|
||||
case "fcm":
|
||||
return "phone_android";
|
||||
case "web":
|
||||
return "public";
|
||||
default:
|
||||
return "notifications";
|
||||
}
|
||||
};
|
||||
|
||||
const getChannelPlatform = (channel: Channel): string | undefined =>
|
||||
(channel.settings as { platform?: string } | null | undefined)?.platform;
|
||||
|
||||
/**
|
||||
* Lists the current user's registered push devices (user-scoped ``push``
|
||||
* channels), lets them enable notifications on the current device — the Web
|
||||
* Push flow in a browser, the OS plugin flow inside the native shells — and
|
||||
* sign a device out.
|
||||
*/
|
||||
export const UserDevicesGrid = () => {
|
||||
const { t } = useTranslation();
|
||||
const config = useConfig();
|
||||
const { user } = useAuth();
|
||||
const modals = useModals();
|
||||
const queryClient = useQueryClient();
|
||||
const [isEnabling, setIsEnabling] = useState(false);
|
||||
// The row whose sign-out is in flight: the spinner must sit on that row
|
||||
// only, while the mutation's pending flag disables every button (one
|
||||
// sign-out at a time).
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// The device being renamed, and the name being typed into its modal.
|
||||
const [renamingDevice, setRenamingDevice] = useState<Channel | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
// This device's `token_hash`, to recognise its own row in the list.
|
||||
const [currentDeviceHash, setCurrentDeviceHash] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data, isLoading, error } = useUsersMeChannelsList();
|
||||
const { mutateAsync: deleteDevice, isPending: isDeleting } =
|
||||
useUsersMeChannelsDestroy();
|
||||
const { mutateAsync: renameDevice, isPending: isRenaming } =
|
||||
useUsersMeChannelsPartialUpdate();
|
||||
|
||||
// ``/users/me/channels/`` returns every user-scoped channel; this view only
|
||||
// manages push devices.
|
||||
const devices = useMemo(
|
||||
() => (data?.data ?? []).filter((c) => c.type === "push"),
|
||||
[data],
|
||||
);
|
||||
|
||||
// Re-keyed on `devices` so enabling / signing out (which invalidate the
|
||||
// list) also refresh whether this device is enrolled.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const resolveHash = isNativePlatform()
|
||||
? currentNativeTokenHash
|
||||
: currentWebPushTokenHash;
|
||||
void resolveHash().then((hash) => {
|
||||
if (!cancelled) setCurrentDeviceHash(hash);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [devices]);
|
||||
|
||||
// Already enrolled: the enable button would only re-upsert the same row,
|
||||
// so it is hidden until a sign-out makes it meaningful again.
|
||||
const isThisDeviceEnrolled =
|
||||
currentDeviceHash !== null &&
|
||||
devices.some((device) => device.token_hash === currentDeviceHash);
|
||||
// Inside a native shell the OS plugin is always available (it carries its
|
||||
// own credentials); a browser can be enabled only when Web Push is
|
||||
// configured server-side (VAPID public key) and the engine supports it.
|
||||
const canEnableThisDevice =
|
||||
!isThisDeviceEnrolled &&
|
||||
(isNativePlatform() ||
|
||||
(isWebPushSupported() && !!config.PUSH_VAPID_PUBLIC_KEY));
|
||||
|
||||
const invalidateDevices = async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getUsersMeChannelsListQueryKey(),
|
||||
exact: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnable = async () => {
|
||||
setIsEnabling(true);
|
||||
try {
|
||||
const result = isNativePlatform()
|
||||
? await enableNativePush(user?.id)
|
||||
: config.PUSH_VAPID_PUBLIC_KEY
|
||||
? await enableWebPush(config.PUSH_VAPID_PUBLIC_KEY, user?.id)
|
||||
: "unsupported";
|
||||
if (result === "subscribed" || result === "registered") {
|
||||
await invalidateDevices();
|
||||
addToast(
|
||||
<ToasterItem type="info">
|
||||
<span>{t("Notifications enabled on this device.")}</span>
|
||||
</ToasterItem>,
|
||||
);
|
||||
} else {
|
||||
// One accurate message per failure mode. "denied" needs
|
||||
// different guidance per runtime: browser site settings vs the
|
||||
// OS app settings (iOS only lets the app prompt once).
|
||||
const messages: Record<string, string> = isNativePlatform()
|
||||
? {
|
||||
denied: t(
|
||||
"Notifications are blocked. Allow them for this app in your device settings.",
|
||||
),
|
||||
unsupported: t(
|
||||
"This device does not support notifications.",
|
||||
),
|
||||
registration_failed: t(
|
||||
"Couldn't register this device for notifications. Check your connection and try again.",
|
||||
),
|
||||
}
|
||||
: {
|
||||
denied: t(
|
||||
"Notifications are blocked. Allow them for this site in your browser settings.",
|
||||
),
|
||||
dismissed: t(
|
||||
"Notification permission was dismissed. Click again to enable.",
|
||||
),
|
||||
unsupported: t(
|
||||
"This browser does not support notifications.",
|
||||
),
|
||||
registration_failed: t(
|
||||
"Couldn't start the notification service worker. Reload the page and try again.",
|
||||
),
|
||||
push_service_error: t(
|
||||
"Couldn't reach the browser's push service. Your network or company firewall may be blocking it. If you use Brave, enable “Use Google services for push messaging” in settings, restart the browser, then try again.",
|
||||
),
|
||||
};
|
||||
addToast(
|
||||
<ToasterItem type="error">
|
||||
<span>{messages[result]}</span>
|
||||
</ToasterItem>,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
handle(err);
|
||||
addToast(
|
||||
<ToasterItem type="error">
|
||||
<span>{t("Failed to enable notifications.")}</span>
|
||||
</ToasterItem>,
|
||||
);
|
||||
} finally {
|
||||
setIsEnabling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = async (channel: Channel) => {
|
||||
const decision = await modals.deleteConfirmationModal({
|
||||
title: (
|
||||
<span className="c__modal__text--centered">
|
||||
{t('Sign out "{{name}}"', { name: channel.name })}
|
||||
</span>
|
||||
),
|
||||
children: t(
|
||||
"This device will stop receiving notifications until you enable them again on it.",
|
||||
),
|
||||
});
|
||||
if (decision !== "delete") {
|
||||
return;
|
||||
}
|
||||
setDeletingId(channel.id);
|
||||
try {
|
||||
// If the signed-out row is this very device, tear the local
|
||||
// registration down first — otherwise the on-load refresh would
|
||||
// re-register it on the next app load, silently undoing the
|
||||
// sign-out. Each helper no-ops off its runtime and for a remote
|
||||
// device. token_hash is the server's sha256 of the token.
|
||||
await unsubscribeIfCurrentBrowser(channel.token_hash, user?.id);
|
||||
await unregisterIfCurrentDevice(channel.token_hash, user?.id);
|
||||
await deleteDevice({ id: channel.id });
|
||||
await invalidateDevices();
|
||||
addToast(
|
||||
<ToasterItem type="info">
|
||||
<span>{t("Device signed out.")}</span>
|
||||
</ToasterItem>,
|
||||
);
|
||||
} catch (err) {
|
||||
handle(err);
|
||||
addToast(
|
||||
<ToasterItem type="error">
|
||||
<span>{t("Failed to sign out device.")}</span>
|
||||
</ToasterItem>,
|
||||
);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openRename = (channel: Channel) => {
|
||||
setRenameValue(channel.name ?? "");
|
||||
setRenamingDevice(channel);
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renamingDevice) return;
|
||||
const name = renameValue.trim();
|
||||
if (!name || name === renamingDevice.name) {
|
||||
setRenamingDevice(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await renameDevice({ id: renamingDevice.id, data: { name } });
|
||||
await invalidateDevices();
|
||||
setRenamingDevice(null);
|
||||
} catch (err) {
|
||||
handle(err);
|
||||
addToast(
|
||||
<ToasterItem type="error">
|
||||
<span>{t("Failed to rename device.")}</span>
|
||||
</ToasterItem>,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Column<Channel>[] = [
|
||||
{
|
||||
id: "name",
|
||||
headerName: t("Name"),
|
||||
// The platform (Apple / Android / Web) is conveyed by the leading
|
||||
// icon — labelled for hover and screen readers — so it no longer
|
||||
// needs its own column, which the narrow modal can't afford.
|
||||
renderCell: ({ row }) => {
|
||||
const platformLabel = getPlatformLabel(
|
||||
getChannelPlatform(row),
|
||||
t,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="flex-row flex-align-center"
|
||||
style={{ gap: "var(--c--globals--spacings--xs)" }}
|
||||
>
|
||||
<Tooltip content={platformLabel}>
|
||||
<span
|
||||
className="flex-row flex-align-center"
|
||||
role="img"
|
||||
aria-label={platformLabel}
|
||||
>
|
||||
<Icon
|
||||
name={getPlatformIcon(getChannelPlatform(row))}
|
||||
type={IconType.OUTLINED}
|
||||
size={IconSize.SMALL}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span>{row.name}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "last_active",
|
||||
headerName: t("Last active"),
|
||||
size: 140,
|
||||
// last_used_at is stamped on every (re)registration — relaunch /
|
||||
// token refresh — so it reflects "last active". Fall back to
|
||||
// created_at for any row that has never been stamped.
|
||||
renderCell: ({ row }) => {
|
||||
const ts = row.last_used_at ?? row.created_at;
|
||||
return ts ? new Date(ts).toLocaleDateString() : "";
|
||||
},
|
||||
},
|
||||
{
|
||||
// Icon buttons only, just wide enough so the name column gets the
|
||||
// reclaimed width. The header still needs a name: an unlabeled
|
||||
// column header reads as nothing to screen readers.
|
||||
id: "actions",
|
||||
headerName: t("Actions"),
|
||||
size: 88,
|
||||
renderCell: ({ row }) => (
|
||||
<div
|
||||
className="flex-row flex-justify-start"
|
||||
style={{ width: "100%", gap: "var(--c--globals--spacings--2xs)" }}
|
||||
>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="nano"
|
||||
onClick={() => openRename(row)}
|
||||
disabled={isDeleting || isRenaming}
|
||||
icon={
|
||||
<Icon
|
||||
name="edit"
|
||||
type={IconType.OUTLINED}
|
||||
size={IconSize.SMALL}
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
aria-label={t("Rename device")}
|
||||
/>
|
||||
<Button
|
||||
color="error"
|
||||
variant="tertiary"
|
||||
size="nano"
|
||||
onClick={() => handleSignOut(row)}
|
||||
disabled={isDeleting}
|
||||
icon={
|
||||
deletingId === row.id ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<Trash size="small" />
|
||||
)
|
||||
}
|
||||
aria-label={t("Sign out")}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const enableToolbar = canEnableThisDevice ? (
|
||||
<div
|
||||
className="flex-row flex-justify-end"
|
||||
style={{ marginBottom: "var(--c--globals--spacings--sm)" }}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleEnable}
|
||||
disabled={isEnabling}
|
||||
icon={isEnabling ? <Spinner size="sm" /> : undefined}
|
||||
>
|
||||
{t("Enable notifications on this device")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
let body: ReactNode;
|
||||
if (isLoading) {
|
||||
body = (
|
||||
<Banner type="info" icon={<Spinner />}>
|
||||
{t("Loading devices...")}
|
||||
</Banner>
|
||||
);
|
||||
} else if (error) {
|
||||
body = <Banner type="error">{t("Error while loading devices")}</Banner>;
|
||||
} else {
|
||||
body = (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
rows={devices}
|
||||
onSortModelChange={() => undefined}
|
||||
enableSorting={false}
|
||||
emptyPlaceholderLabel={
|
||||
<span style={{ textAlign: "center" }}>
|
||||
{t("No devices yet. Enable notifications on this device.")}
|
||||
</span> as unknown as string
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-data-grid">
|
||||
{enableToolbar}
|
||||
{body}
|
||||
{renamingDevice && (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={() => setRenamingDevice(null)}
|
||||
size={ModalSize.SMALL}
|
||||
title={t("Rename device")}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setRenamingDevice(null)}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleRename}
|
||||
disabled={isRenaming || !renameValue.trim()}
|
||||
icon={
|
||||
isRenaming ? <Spinner size="sm" /> : undefined
|
||||
}
|
||||
>
|
||||
{t("Rename")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t("Name")}
|
||||
value={renameValue}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
maxLength={255}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* The security-sensitive Web Push client logic: the VAPID-rotation detection
|
||||
* (a rotated key silently kills every subscription — the backend can't see it),
|
||||
* and the shared-computer arbitration in `refreshWebPushSubscription` (a
|
||||
* leftover subscription must never keep alerting for a previous user, nor be
|
||||
* silently adopted by the next one).
|
||||
*/
|
||||
import { hashEndpoint, hasPushOptIn, setPushOptIn } from "@/features/push/shared";
|
||||
|
||||
// Repo pattern for the generated client: automock the resource submodule (the
|
||||
// index re-exports it, so web-push.ts sees the mock through "@/features/api/gen").
|
||||
vi.mock("@/features/api/gen/channels/channels");
|
||||
vi.mock("@/features/api/utils", () => ({
|
||||
getApiOrigin: () => "https://api.test",
|
||||
}));
|
||||
// swUrl stamps the current language into the worker URL; the real instance
|
||||
// would drag the http backend into the test environment.
|
||||
vi.mock("@/features/i18n/initI18n", () => ({
|
||||
default: { resolvedLanguage: "en", language: "en" },
|
||||
}));
|
||||
|
||||
import {
|
||||
usersMeChannelsCreate,
|
||||
usersMeChannelsList,
|
||||
} from "@/features/api/gen";
|
||||
|
||||
import {
|
||||
currentWebPushTokenHash,
|
||||
isStaleForKey,
|
||||
refreshWebPushSubscription,
|
||||
unsubscribeIfCurrentBrowser,
|
||||
urlBase64ToUint8Array,
|
||||
} from "./web-push";
|
||||
|
||||
const USER_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
const b64url = (bytes: Uint8Array): string =>
|
||||
btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
/** Two distinct, well-formed 65-byte uncompressed P-256 points. */
|
||||
const VAPID_KEY = b64url(new Uint8Array(65).map((_, i) => i));
|
||||
const ROTATED_KEY = b64url(new Uint8Array(65).map((_, i) => 64 - i));
|
||||
|
||||
type FakeSubscription = {
|
||||
endpoint: string;
|
||||
options: { applicationServerKey: ArrayBuffer | null };
|
||||
toJSON: () => {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
};
|
||||
unsubscribe: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const makeSubscription = ({
|
||||
endpoint = "https://push.example/ep-1",
|
||||
serverKey = VAPID_KEY,
|
||||
}: { endpoint?: string; serverKey?: string | null } = {}): FakeSubscription => ({
|
||||
endpoint,
|
||||
options: {
|
||||
applicationServerKey: serverKey
|
||||
? urlBase64ToUint8Array(serverKey).buffer
|
||||
: null,
|
||||
},
|
||||
toJSON: () => ({ endpoint, keys: { p256dh: "AAAA", auth: "BBBB" } }),
|
||||
unsubscribe: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
|
||||
const asSubscription = (sub: FakeSubscription): PushSubscription =>
|
||||
sub as unknown as PushSubscription;
|
||||
|
||||
const registerDevice = vi.mocked(usersMeChannelsCreate);
|
||||
const listDevices = vi.mocked(usersMeChannelsList);
|
||||
|
||||
/** A navigator.serviceWorker whose registration hands out `subscription` and
|
||||
* subscribes to `subscribed` afterwards. */
|
||||
const stubServiceWorker = ({
|
||||
subscription = null as FakeSubscription | null,
|
||||
subscribed = makeSubscription(),
|
||||
registered = true,
|
||||
} = {}) => {
|
||||
const pushManager = {
|
||||
getSubscription: vi.fn().mockResolvedValue(subscription),
|
||||
subscribe: vi.fn().mockResolvedValue(subscribed),
|
||||
};
|
||||
const registration = { pushManager };
|
||||
const serviceWorker = {
|
||||
getRegistration: vi.fn().mockResolvedValue(registered ? registration : null),
|
||||
register: vi.fn().mockResolvedValue(registration),
|
||||
ready: Promise.resolve(registration),
|
||||
};
|
||||
vi.stubGlobal("navigator", {
|
||||
serviceWorker,
|
||||
userAgent: "Mozilla/5.0 (Macintosh) Chrome/143.0.0.0 Safari/537.36",
|
||||
});
|
||||
return { pushManager, serviceWorker };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
// isWebPushSupported() gates every entry point on these three.
|
||||
vi.stubGlobal("PushManager", class {});
|
||||
vi.stubGlobal("Notification", { permission: "granted" });
|
||||
registerDevice.mockResolvedValue(
|
||||
{} as Awaited<ReturnType<typeof usersMeChannelsCreate>>,
|
||||
);
|
||||
listDevices.mockResolvedValue({
|
||||
data: [],
|
||||
} as unknown as Awaited<ReturnType<typeof usersMeChannelsList>>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("urlBase64ToUint8Array", () => {
|
||||
it("decodes an unpadded base64url key to its 65 bytes", () => {
|
||||
const bytes = urlBase64ToUint8Array(VAPID_KEY);
|
||||
expect(bytes).toHaveLength(65);
|
||||
expect(bytes[0]).toBe(0);
|
||||
expect(bytes[64]).toBe(64);
|
||||
});
|
||||
|
||||
it("accepts the standard-base64 alphabet the server may serve", () => {
|
||||
const standard = btoa(
|
||||
String.fromCharCode(...new Uint8Array(65).map((_, i) => i)),
|
||||
);
|
||||
expect(urlBase64ToUint8Array(standard)).toEqual(
|
||||
urlBase64ToUint8Array(VAPID_KEY),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects anything that is not a 65-byte P-256 point", () => {
|
||||
expect(() => urlBase64ToUint8Array(b64url(new Uint8Array(64)))).toThrow(
|
||||
/65 bytes/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStaleForKey", () => {
|
||||
it("matches the same key across base64 spellings (no spurious staleness)", () => {
|
||||
const sub = makeSubscription({ serverKey: VAPID_KEY });
|
||||
const padded = VAPID_KEY.replace(/-/g, "+").replace(/_/g, "/") + "=";
|
||||
expect(isStaleForKey(asSubscription(sub), padded)).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a subscription created under a rotated-away key", () => {
|
||||
const sub = makeSubscription({ serverKey: ROTATED_KEY });
|
||||
expect(isStaleForKey(asSubscription(sub), VAPID_KEY)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a subscription whose engine hides the key (can't prove a mismatch)", () => {
|
||||
const sub = makeSubscription({ serverKey: null });
|
||||
expect(isStaleForKey(asSubscription(sub), VAPID_KEY)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentWebPushTokenHash", () => {
|
||||
// The devices grid uses it to spot this device's row (hide "enable" when
|
||||
// already enrolled), so it must match the server-side token_hash exactly.
|
||||
it("hashes the live subscription's endpoint like the server does", async () => {
|
||||
const sub = makeSubscription();
|
||||
stubServiceWorker({ subscription: sub });
|
||||
expect(await currentWebPushTokenHash()).toBe(
|
||||
await hashEndpoint(sub.endpoint),
|
||||
);
|
||||
});
|
||||
|
||||
it("is null without a live subscription", async () => {
|
||||
stubServiceWorker({ subscription: null });
|
||||
expect(await currentWebPushTokenHash()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsubscribeIfCurrentBrowser", () => {
|
||||
it("tears down this browser's subscription and drops the opt-in", async () => {
|
||||
const sub = makeSubscription();
|
||||
stubServiceWorker({ subscription: sub });
|
||||
setPushOptIn(USER_ID);
|
||||
|
||||
await unsubscribeIfCurrentBrowser(await hashEndpoint(sub.endpoint), USER_ID);
|
||||
|
||||
expect(sub.unsubscribe).toHaveBeenCalled();
|
||||
expect(hasPushOptIn(USER_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops for a remote device row", async () => {
|
||||
const sub = makeSubscription();
|
||||
stubServiceWorker({ subscription: sub });
|
||||
setPushOptIn(USER_ID);
|
||||
|
||||
await unsubscribeIfCurrentBrowser(
|
||||
await hashEndpoint("https://push.example/other"),
|
||||
USER_ID,
|
||||
);
|
||||
|
||||
expect(sub.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(hasPushOptIn(USER_ID)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshWebPushSubscription", () => {
|
||||
it("stays passive when the user never enabled push here", async () => {
|
||||
const { serviceWorker } = stubServiceWorker({ registered: false });
|
||||
await refreshWebPushSubscription(VAPID_KEY, USER_ID);
|
||||
expect(serviceWorker.register).not.toHaveBeenCalled();
|
||||
expect(registerDevice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays passive without the notification permission", async () => {
|
||||
vi.stubGlobal("Notification", { permission: "default" });
|
||||
const { serviceWorker } = stubServiceWorker();
|
||||
await refreshWebPushSubscription(VAPID_KEY, USER_ID);
|
||||
expect(serviceWorker.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-registers an opted-in user's live subscription", async () => {
|
||||
const sub = makeSubscription();
|
||||
stubServiceWorker({ subscription: sub });
|
||||
setPushOptIn(USER_ID);
|
||||
|
||||
await refreshWebPushSubscription(VAPID_KEY, USER_ID);
|
||||
|
||||
expect(registerDevice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ token: sub.endpoint }),
|
||||
);
|
||||
expect(sub.unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-subscribes an opted-in user whose subscription is gone", async () => {
|
||||
const fresh = makeSubscription({ endpoint: "https://push.example/fresh" });
|
||||
const { pushManager } = stubServiceWorker({
|
||||
subscription: null,
|
||||
subscribed: fresh,
|
||||
});
|
||||
setPushOptIn(USER_ID);
|
||||
|
||||
await refreshWebPushSubscription(VAPID_KEY, USER_ID);
|
||||
|
||||
expect(pushManager.subscribe).toHaveBeenCalled();
|
||||
expect(registerDevice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ token: fresh.endpoint }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adopts a marker-less subscription only on proven server ownership", async () => {
|
||||
// Legacy user (opted in before the marker existed): their device list
|
||||
// holds this endpoint's hash, so the refresh migrates them onto the marker.
|
||||
const sub = makeSubscription();
|
||||
stubServiceWorker({ subscription: sub });
|
||||
listDevices.mockResolvedValue({
|
||||
data: [{ type: "push", token_hash: await hashEndpoint(sub.endpoint) }],
|
||||
} as unknown as Awaited<ReturnType<typeof usersMeChannelsList>>);
|
||||
|
||||
await refreshWebPushSubscription(VAPID_KEY, USER_ID);
|
||||
|
||||
expect(registerDevice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ token: sub.endpoint }),
|
||||
);
|
||||
expect(hasPushOptIn(USER_ID)).toBe(true);
|
||||
expect(sub.unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tears down a previous user's leftover on a shared computer", async () => {
|
||||
// No marker for this user and the server doesn't own the endpoint: the
|
||||
// subscription belongs to whoever used this browser before. Killing it
|
||||
// locally stops their alerts; it must never be re-registered as ours.
|
||||
const sub = makeSubscription();
|
||||
stubServiceWorker({ subscription: sub });
|
||||
|
||||
await refreshWebPushSubscription(VAPID_KEY, USER_ID);
|
||||
|
||||
expect(sub.unsubscribe).toHaveBeenCalled();
|
||||
expect(registerDevice).not.toHaveBeenCalled();
|
||||
expect(hasPushOptIn(USER_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("re-subscribes with the current key after a VAPID rotation", async () => {
|
||||
const stale = makeSubscription({ serverKey: ROTATED_KEY });
|
||||
const fresh = makeSubscription({ endpoint: "https://push.example/fresh" });
|
||||
const { pushManager } = stubServiceWorker({
|
||||
subscription: stale,
|
||||
subscribed: fresh,
|
||||
});
|
||||
setPushOptIn(USER_ID);
|
||||
|
||||
await refreshWebPushSubscription(VAPID_KEY, USER_ID);
|
||||
|
||||
expect(stale.unsubscribe).toHaveBeenCalled();
|
||||
expect(pushManager.subscribe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationServerKey: urlBase64ToUint8Array(VAPID_KEY),
|
||||
}),
|
||||
);
|
||||
expect(registerDevice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ token: fresh.endpoint }),
|
||||
);
|
||||
});
|
||||
});
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Web Push enable flow for browsers / installed PWAs.
|
||||
*
|
||||
* Native (Capacitor) builds use the OS push plugins instead — this path is only
|
||||
* for the `web` transport. Registers the service worker, requests notification
|
||||
* permission, subscribes with the server's VAPID public key, and registers the
|
||||
* resulting subscription as a user-scoped `push` channel via the API.
|
||||
*/
|
||||
import {
|
||||
PlatformEnum,
|
||||
PushChannelCreateTypeEnum,
|
||||
usersMeChannelsCreate,
|
||||
usersMeChannelsList,
|
||||
} from "@/features/api/gen";
|
||||
import { getApiOrigin } from "@/features/api/utils";
|
||||
import i18n from "@/features/i18n/initI18n";
|
||||
import {
|
||||
clearPushOptIn,
|
||||
hashEndpoint,
|
||||
hasPushOptIn,
|
||||
setPushOptIn,
|
||||
} from "@/features/push/shared";
|
||||
|
||||
/**
|
||||
* The service worker's push handler enriches notifications by fetching the
|
||||
* message over the authenticated session, but `sw.js` is a static file that
|
||||
* can't read `import.meta.env`. When the API lives on a different origin than
|
||||
* the app (dev: front `:8900` / API `:8901`), a same-origin fetch never reaches
|
||||
* the backend and enrichment silently falls back to a generic banner. So we
|
||||
* carry the API origin in the registration URL's query string: it becomes part
|
||||
* of the stored `scriptURL`, so `self.location.search` reads it back even when
|
||||
* the worker cold-starts for a headless push (no page open). Scope stays `/`
|
||||
* (query params don't affect scope), so the push subscription survives this
|
||||
* script-url change.
|
||||
*
|
||||
* We also carry the current VAPID public key (`?vapid=`): the worker's
|
||||
* `pushsubscriptionchange` handler must re-subscribe with the *current* key, not
|
||||
* the one on the (possibly rotated-away) old subscription, or it would recreate a
|
||||
* dead subscription. A key rotation changes this URL, so the next re-register
|
||||
* pulls the new `?vapid=`.
|
||||
*
|
||||
* And the UI language (`?lang=`): the worker's generic fallback banner (shown
|
||||
* when enrichment fails) must speak the user's language, and it can't load
|
||||
* i18next. A language switch reaches the worker through the same on-load
|
||||
* re-registration as the other params.
|
||||
*/
|
||||
const swUrl = (vapidPublicKey: string): string =>
|
||||
`/sw.js?api=${encodeURIComponent(getApiOrigin())}&vapid=${encodeURIComponent(
|
||||
vapidPublicKey,
|
||||
)}&lang=${encodeURIComponent(i18n.resolvedLanguage ?? i18n.language ?? "en")}`;
|
||||
|
||||
export type EnableWebPushResult =
|
||||
| "subscribed"
|
||||
| "denied" // permission explicitly refused (needs OS/browser settings)
|
||||
| "dismissed" // prompt closed without choosing — retrying is fine
|
||||
| "unsupported"
|
||||
| "registration_failed" // the service worker (/sw.js) failed to register
|
||||
| "push_service_error"; // browser↔push-service handshake failed (e.g. Brave)
|
||||
|
||||
/** True when this browser can do Web Push at all. */
|
||||
export const isWebPushSupported = (): boolean =>
|
||||
typeof navigator !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
typeof window !== "undefined" &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window;
|
||||
|
||||
/** Decode a base64url VAPID key into the BufferSource subscribe() expects.
|
||||
* Backed by an explicit ArrayBuffer so the type is the non-shared
|
||||
* Uint8Array<ArrayBuffer> applicationServerKey requires.
|
||||
*
|
||||
* Throws if the result isn't a 65-byte uncompressed P-256 point: that's the
|
||||
* only shape a VAPID applicationServerKey can be, and a malformed key (e.g. a
|
||||
* mis-pinned PUSH_VAPID_PUBLIC_KEY) otherwise fails later inside subscribe()
|
||||
* with an opaque error. Failing here surfaces the real cause. */
|
||||
export const urlBase64ToUint8Array = (
|
||||
base64String: string,
|
||||
): Uint8Array<ArrayBuffer> => {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(base64);
|
||||
const output = new Uint8Array(new ArrayBuffer(raw.length));
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
output[i] = raw.charCodeAt(i);
|
||||
}
|
||||
if (output.length !== 65) {
|
||||
throw new Error(
|
||||
`Invalid VAPID public key: expected 65 bytes, got ${output.length}`,
|
||||
);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
/** Coarse OS label from the UA string. The actual machine/host name is not
|
||||
* exposed to a browser (privacy), so the OS is the most specific "which device"
|
||||
* hint we can attach to the browser name. */
|
||||
const osName = (ua: string): string | undefined => {
|
||||
if (/Windows NT/.test(ua)) return "Windows";
|
||||
if (/(iPhone|iPad|iPod)/.test(ua)) return "iOS";
|
||||
if (/Macintosh|Mac OS X/.test(ua)) return "macOS";
|
||||
if (/Android/.test(ua)) return "Android";
|
||||
if (/Linux/.test(ua)) return "Linux";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Best-effort human label so the device list can tell browsers apart.
|
||||
* Leads with the OS then the browser (e.g. "macOS — Chrome") so a user with the
|
||||
* same browser on several machines can distinguish them; falls back to the
|
||||
* browser alone when the OS is unrecognised. The transport ("web") is already
|
||||
* shown by the platform icon, so it isn't repeated here. */
|
||||
const deviceName = (): string => {
|
||||
const ua = navigator.userAgent;
|
||||
let browser = "Browser";
|
||||
if (/Edg\//.test(ua)) browser = "Edge";
|
||||
else if (/OPR\//.test(ua) || /Opera/.test(ua)) browser = "Opera";
|
||||
else if (/Chrome\//.test(ua)) browser = "Chrome";
|
||||
else if (/Firefox\//.test(ua)) browser = "Firefox";
|
||||
else if (/Safari\//.test(ua)) browser = "Safari";
|
||||
const os = osName(ua);
|
||||
return os ? `${os} - ${browser}` : browser;
|
||||
};
|
||||
|
||||
/** This browser's current push subscription, or null when the user never
|
||||
* enabled push here (unsupported engine / no SW registration / no subscription).
|
||||
* Never registers a worker — purely a read. */
|
||||
export const getCurrentSubscription =
|
||||
async (): Promise<PushSubscription | null> => {
|
||||
if (!isWebPushSupported()) return null;
|
||||
const registration =
|
||||
await navigator.serviceWorker.getRegistration("/sw.js");
|
||||
if (!registration) return null;
|
||||
return registration.pushManager.getSubscription();
|
||||
};
|
||||
|
||||
/** `token_hash` of this browser's live subscription, or null without one.
|
||||
* Matches the server rows' `token_hash`, so the UI can recognise this device
|
||||
* in the list (e.g. hide "enable" when it is already enrolled). */
|
||||
export const currentWebPushTokenHash = async (): Promise<string | null> => {
|
||||
try {
|
||||
const subscription = await getCurrentSubscription();
|
||||
return subscription ? await hashEndpoint(subscription.endpoint) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type SubscriptionParts = {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
};
|
||||
|
||||
/** Pull the endpoint + encryption keys the registration API needs, or null when
|
||||
* the subscription is somehow incomplete. */
|
||||
const subscriptionParts = (
|
||||
subscription: PushSubscription,
|
||||
): SubscriptionParts | null => {
|
||||
const json = subscription.toJSON();
|
||||
const p256dh = json.keys?.p256dh;
|
||||
const auth = json.keys?.auth;
|
||||
if (!json.endpoint || !p256dh || !auth) return null;
|
||||
return { endpoint: json.endpoint, keys: { p256dh, auth } };
|
||||
};
|
||||
|
||||
/** Upsert a subscription as the user's web `push` channel. The backend keys on
|
||||
* the token hash, so re-running is idempotent. */
|
||||
const registerWebPushSubscription = (
|
||||
parts: SubscriptionParts,
|
||||
): Promise<unknown> =>
|
||||
usersMeChannelsCreate({
|
||||
type: PushChannelCreateTypeEnum.push,
|
||||
platform: PlatformEnum.web,
|
||||
token: parts.endpoint,
|
||||
keys: parts.keys,
|
||||
name: deviceName(),
|
||||
});
|
||||
|
||||
/** Normalise a base64 / base64url key to unpadded base64url, so a `/config` key
|
||||
* with padding or standard `+//` alphabet compares equal to the browser-derived
|
||||
* one (else every load would spuriously look "stale"). */
|
||||
const toBase64Url = (s: string): string =>
|
||||
s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
|
||||
/** Unpadded base64url of the subscription's `applicationServerKey`, or null when
|
||||
* the engine doesn't expose it (older Safari) — in which case we can't prove a
|
||||
* mismatch and must keep the existing subscription. */
|
||||
const subscriptionServerKey = (
|
||||
subscription: PushSubscription,
|
||||
): string | null => {
|
||||
const raw = subscription.options?.applicationServerKey;
|
||||
if (!raw) return null;
|
||||
const bytes = new Uint8Array(raw);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return toBase64Url(btoa(binary));
|
||||
};
|
||||
|
||||
/** True when the existing subscription was created with a *different* VAPID key
|
||||
* than the server now advertises. After a server-side key rotation the push
|
||||
* service rejects the old subscription (401/403) forever — the backend can't
|
||||
* detect this (it only prunes on 404/410), so the client must tear the dead
|
||||
* subscription down and re-create it with the current key. */
|
||||
export const isStaleForKey = (
|
||||
subscription: PushSubscription,
|
||||
vapidPublicKey: string,
|
||||
): boolean => {
|
||||
const current = subscriptionServerKey(subscription);
|
||||
return current !== null && current !== toBase64Url(vapidPublicKey);
|
||||
};
|
||||
|
||||
/** If the given server device row (identified by its `token_hash`) is *this*
|
||||
* browser's subscription, unsubscribe locally first — otherwise the on-load
|
||||
* `refreshWebPushSubscription` would immediately recreate the channel we just
|
||||
* deleted. No-op for a remote device or when this browser has no subscription.
|
||||
* Best-effort. */
|
||||
export const unsubscribeIfCurrentBrowser = async (
|
||||
tokenHash: string | null | undefined,
|
||||
userId?: string,
|
||||
): Promise<void> => {
|
||||
if (!tokenHash) return;
|
||||
try {
|
||||
const subscription = await getCurrentSubscription();
|
||||
if (!subscription) return;
|
||||
const localHash = await hashEndpoint(subscription.endpoint);
|
||||
if (localHash === tokenHash) {
|
||||
await subscription.unsubscribe();
|
||||
// Explicit opt-out on this device: drop the marker so the on-load refresh
|
||||
// doesn't auto-re-subscribe this user next time.
|
||||
clearPushOptIn(userId);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; sign-out proceeds regardless.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the full enable flow. Returns a discriminated outcome for every expected
|
||||
* non-success case (so the caller can show an accurate message); throws only on
|
||||
* truly unexpected failures.
|
||||
*
|
||||
* - "dismissed": the user closed the permission prompt without choosing — safe
|
||||
* to retry, nothing is blocked.
|
||||
* - "denied": permission explicitly refused — needs OS/browser settings.
|
||||
* - "push_service_error": permission granted but `subscribe()` failed to register
|
||||
* with the browser's push service (an `AbortError`). The classic cause is Brave
|
||||
* with "Use Google services for push messaging" off, or no network to the push
|
||||
* service — retryable, but needs that setting flipped.
|
||||
*/
|
||||
export const enableWebPush = async (
|
||||
vapidPublicKey: string,
|
||||
userId?: string,
|
||||
): Promise<EnableWebPushResult> => {
|
||||
if (!isWebPushSupported()) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === "denied") {
|
||||
return "denied";
|
||||
}
|
||||
if (permission !== "granted") {
|
||||
return "dismissed"; // "default" — prompt closed without a choice
|
||||
}
|
||||
|
||||
let registration: ServiceWorkerRegistration;
|
||||
try {
|
||||
registration = await navigator.serviceWorker.register(swUrl(vapidPublicKey));
|
||||
await navigator.serviceWorker.ready;
|
||||
} catch {
|
||||
// /sw.js missing (404), blocked by CSP, or otherwise unregisterable —
|
||||
// distinct from a permission or push-service failure.
|
||||
return "registration_failed";
|
||||
}
|
||||
|
||||
// Reuse an existing subscription only if it was created with the *current*
|
||||
// VAPID key; after a server key rotation the old subscription is dead (the
|
||||
// push service returns 401/403), so drop it and subscribe afresh.
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (subscription && isStaleForKey(subscription, vapidPublicKey)) {
|
||||
await subscription.unsubscribe();
|
||||
subscription = null;
|
||||
}
|
||||
if (!subscription) {
|
||||
try {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
} catch (err) {
|
||||
// `subscribe()` rejects with AbortError ("Registration failed - push
|
||||
// service error") when the browser can't reach/register with its push
|
||||
// service. Report it distinctly rather than as a generic failure.
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
// Surface the browser's diagnostic for support cases: this handshake
|
||||
// is browser-internal, so without this warn the failure leaves no
|
||||
// trace at all in DevTools (no console error, no network request).
|
||||
console.warn("Web Push subscribe failed:", err.message);
|
||||
return "push_service_error";
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const parts = subscriptionParts(subscription);
|
||||
if (!parts) {
|
||||
throw new Error("Incomplete push subscription");
|
||||
}
|
||||
|
||||
// Push devices register through the generic channels create with type=push;
|
||||
// the backend upserts on the token's hash (globally unique), so re-running
|
||||
// this is idempotent.
|
||||
await registerWebPushSubscription(parts);
|
||||
|
||||
// Remember this user's explicit opt-in on this browser so a later login
|
||||
// (after a logout teardown) can re-subscribe them automatically.
|
||||
setPushOptIn(userId);
|
||||
|
||||
return "subscribed";
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-register the *existing* subscription on app load, to self-heal a rotated
|
||||
* endpoint and refresh `last_used_at`.
|
||||
*
|
||||
* Complements the service worker's `pushsubscriptionchange` handler: that fires
|
||||
* only while the browser is running and (on non-Chromium engines) can't attach
|
||||
* the CSRF token, so this re-posts through the app's normal, CSRF-correct API
|
||||
* client whenever the app opens. It acts only for users who explicitly opted in
|
||||
* on this browser (permission granted + a live subscription, *or* the persisted
|
||||
* per-user opt-in marker); it never prompts and never registers a worker for a
|
||||
* user who never enabled push.
|
||||
*
|
||||
* `userId` gates the re-registration: a voluntary logout deletes the server
|
||||
* channel (see `core.signals`), and this refresh is what recreates it on the
|
||||
* returning user's next login — silently, but only if *they* are the one who
|
||||
* enabled push here (`hasPushOptIn`). A different user on a shared computer —
|
||||
* with no marker — is left untouched, so they never inherit the subscription.
|
||||
*
|
||||
* When a live subscription exists it *does* re-`register(swUrl(...))`, so a
|
||||
* changed `sw.js` body and the `?api=`/`?vapid=` params self-heal on every app
|
||||
* load rather than only when the user re-toggles push in settings. This is safe:
|
||||
* permission is already granted (no prompt) and the scope stays `/`, so the
|
||||
* existing push subscription survives the script-url refresh. It also
|
||||
* re-subscribes with the current VAPID key if the key rotated. Best-effort: any
|
||||
* failure is swallowed.
|
||||
*/
|
||||
export const refreshWebPushSubscription = async (
|
||||
vapidPublicKey: string,
|
||||
userId?: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (!isWebPushSupported() || !vapidPublicKey) return;
|
||||
if (Notification.permission !== "granted") return;
|
||||
|
||||
// Gate on an existing registration so we stay passive for never-opted-in
|
||||
// users, then re-register to pull the latest sw.js and the current
|
||||
// `?api=`/`?vapid=`.
|
||||
const existing = await navigator.serviceWorker.getRegistration("/sw.js");
|
||||
if (!existing) return; // user never enabled push in this browser
|
||||
const registration = await navigator.serviceWorker.register(
|
||||
swUrl(vapidPublicKey),
|
||||
);
|
||||
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
// No live subscription. Re-subscribe only if THIS user opted in on this
|
||||
// browser — otherwise stay passive so a different user on a shared
|
||||
// computer isn't silently enrolled.
|
||||
if (!hasPushOptIn(userId)) return;
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
} else {
|
||||
// A live subscription is NOT proof this user opted in: on a shared
|
||||
// computer it may be a previous user's leftover (a voluntary logout only
|
||||
// deletes the server channel; an expired session deletes nothing).
|
||||
// Entitlement = the opt-in marker, or — for users who enabled push
|
||||
// before the marker existed — server-side ownership of this endpoint
|
||||
// (their device list contains its hash).
|
||||
if (!hasPushOptIn(userId)) {
|
||||
const localHash = await hashEndpoint(subscription.endpoint);
|
||||
const response = await usersMeChannelsList();
|
||||
const owned = (response.data ?? []).some(
|
||||
(c) => c.type === "push" && c.token_hash === localHash,
|
||||
);
|
||||
if (!owned) {
|
||||
// A *different* user is now authenticated in this browser: the
|
||||
// previous user's device must stop alerting. We can't DELETE their
|
||||
// channel (not ours), but unsubscribing is browser-local and needs
|
||||
// no permission — the endpoint dies at the push service, so nothing
|
||||
// is delivered anymore, and the orphaned server channel self-prunes
|
||||
// on its next send (404/410 → stale). The previous user's opt-in
|
||||
// marker survives, so when THEY next log in here they get a fresh
|
||||
// subscription automatically.
|
||||
await subscription.unsubscribe();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isStaleForKey(subscription, vapidPublicKey)) {
|
||||
// Key rotated under us: the existing subscription is dead, so
|
||||
// re-subscribe with the current key. Permission is already granted
|
||||
// (no prompt), so this stays on the passive path.
|
||||
await subscription.unsubscribe();
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const parts = subscriptionParts(subscription);
|
||||
if (!parts) return;
|
||||
|
||||
await registerWebPushSubscription(parts);
|
||||
// (Re)assert the marker: entitlement was established above, and legacy
|
||||
// users (opted in before the marker existed) get migrated onto it here.
|
||||
setPushOptIn(userId);
|
||||
} catch {
|
||||
// Best-effort refresh — never disrupt app load.
|
||||
}
|
||||
};
|
||||
|
||||
/** Mirror of `PUSH_SUBSCRIPTION_CHANGED` in `public/sw.js`: the message the
|
||||
* worker posts after it re-subscribes on `pushsubscriptionchange`. */
|
||||
const PUSH_SUBSCRIPTION_CHANGED = "push-subscription-changed";
|
||||
|
||||
type PushSubscriptionChangedMessage = {
|
||||
type: typeof PUSH_SUBSCRIPTION_CHANGED;
|
||||
subscription: { endpoint: string; keys: { p256dh: string; auth: string } };
|
||||
};
|
||||
|
||||
const isPushSubscriptionChangedMessage = (
|
||||
data: unknown,
|
||||
): data is PushSubscriptionChangedMessage => {
|
||||
if (typeof data !== "object" || data === null) return false;
|
||||
const msg = data as Record<string, unknown>;
|
||||
if (msg.type !== PUSH_SUBSCRIPTION_CHANGED) return false;
|
||||
const sub = msg.subscription as Record<string, unknown> | undefined;
|
||||
const keys = sub?.keys as Record<string, unknown> | undefined;
|
||||
return (
|
||||
typeof sub?.endpoint === "string" &&
|
||||
typeof keys?.p256dh === "string" &&
|
||||
typeof keys?.auth === "string"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Register the app-side listener for the worker's `pushsubscriptionchange`
|
||||
* hand-off, and register the new endpoint through the app's API client.
|
||||
*
|
||||
* The worker re-`subscribe()`s on its own, but under `CSRF_USE_SESSIONS` it
|
||||
* can't sign the registration POST — the CSRF token is no longer a cookie, it
|
||||
* lives in this page's memory (delivered via `/users/me/`). So the worker posts
|
||||
* the fresh subscription here and the app registers it with the correct CSRF
|
||||
* header. Returns a cleanup that removes the listener; no-ops where service
|
||||
* workers are unavailable. `refreshWebPushSubscription` remains the fallback
|
||||
* when no page was open at rotation time.
|
||||
*/
|
||||
export const listenForPushSubscriptionChange = (): (() => void) => {
|
||||
if (!isWebPushSupported()) return () => {};
|
||||
const handler = (event: MessageEvent): void => {
|
||||
if (!isPushSubscriptionChangedMessage(event.data)) return;
|
||||
void registerWebPushSubscription(event.data.subscription).catch(() => {
|
||||
// Best-effort; refreshWebPushSubscription reconciles on the next load.
|
||||
});
|
||||
};
|
||||
navigator.serviceWorker.addEventListener("message", handler);
|
||||
return () => navigator.serviceWorker.removeEventListener("message", handler);
|
||||
};
|
||||
@@ -18,7 +18,9 @@ import { CircularProgress } from "@/features/ui/components/circular-progress";
|
||||
import { useTheme } from "@/features/providers/theme";
|
||||
import { MODAL_MAILBOX_SETTINGS_ID } from "@/features/layouts/components/mailbox-settings/modal-mailbox-settings";
|
||||
import { useOpenImporter } from "@/features/layouts/components/mailbox-settings/imports-view/use-open-importer";
|
||||
import { MODAL_NOTIFICATIONS_ID } from "@/features/layouts/components/notifications-settings/modal-notifications";
|
||||
import { useModalStore } from "@/features/providers/modal-store";
|
||||
import { useConfig } from "@/features/providers/config";
|
||||
|
||||
|
||||
type AuthenticatedHeaderProps = HeaderProps & {
|
||||
@@ -227,10 +229,14 @@ const ApplicationMenu = () => {
|
||||
const canManageIntegrations = canManageMessageTemplates && isIntegrationsEnabled;
|
||||
const canAdministrateSelectedMailbox = useAbility(Abilities.CAN_MANAGE_ACCESSES, selectedMailbox);
|
||||
const canOpenMailboxSettings = canAdministrateSelectedMailbox || canManageMessageTemplates || canManageIntegrations;
|
||||
// Notifications/devices are user-scoped, so every user sees this entry when
|
||||
// push is enabled — independent of any mailbox ability.
|
||||
const config = useConfig();
|
||||
const canManageNotifications = config.PUSH_ENABLED;
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const hasOptions = canAccessDomainAdmin || canImportMessages || canOpenMailboxSettings;
|
||||
const hasOptions = canAccessDomainAdmin || canImportMessages || canOpenMailboxSettings || canManageNotifications;
|
||||
// Live progress moved to the header ImportIndicator (which reads the imports
|
||||
// resource); the menu entry just opens the importer.
|
||||
const importMessageOption = {
|
||||
@@ -268,6 +274,12 @@ const ApplicationMenu = () => {
|
||||
showSeparator: canAccessDomainAdmin && !canImportMessages
|
||||
}] : []),
|
||||
...(canImportMessages ? [importMessageOption] : []),
|
||||
...(canManageNotifications ? [{
|
||||
label: t("Notifications"),
|
||||
icon: <Icon name="notifications" style={{ fontSize: 24 }} />,
|
||||
callback: () => openModal(MODAL_NOTIFICATIONS_ID),
|
||||
showSeparator: canAccessDomainAdmin,
|
||||
}] : []),
|
||||
...(canAccessDomainAdmin ? [{
|
||||
label: t("Domain admin"),
|
||||
icon: <Icon name="domain" style={{ fontSize: 24 }} />,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ModalStoreProvider } from "@/features/providers/modal-store";
|
||||
import { ScrollRestoreProvider } from "@/features/providers/scroll-restore";
|
||||
import { AttachmentPreviewProvider } from "@/features/providers/attachment-preview";
|
||||
import { useTheme } from "@/features/providers/theme";
|
||||
import { useUnreadBadge } from "@/features/providers/use-unread-badge";
|
||||
import { LayoutProvider, useLayoutDragContext } from "@/features/layouts/components/layout-context";
|
||||
import { AttachmentPreviewModal } from "@/features/layouts/components/thread-view/components/attachment-preview-modal";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
@@ -42,6 +43,8 @@ const MainLayoutContent = ({ children }: PropsWithChildren<{ simple?: boolean }>
|
||||
const { theme, variant } = useTheme();
|
||||
const { isLeftPanelOpen, setIsLeftPanelOpen, isDragging } = useLayoutDragContext();
|
||||
|
||||
useUnreadBadge();
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
enableResize
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useConfig } from "@/features/providers/config";
|
||||
|
||||
import { UserDevicesGrid } from "../mailbox-settings/devices-view/user-devices-grid";
|
||||
|
||||
export const MODAL_NOTIFICATIONS_ID = "modal-notifications";
|
||||
|
||||
type ModalNotificationsProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Account-level notifications settings.
|
||||
*
|
||||
* Unlike the mailbox settings modal this is *user*-scoped — push devices are
|
||||
* personal and span every mailbox — so it is reachable by every authenticated
|
||||
* user from the header menu, not gated on mailbox-admin abilities.
|
||||
*
|
||||
* Controlled via `isOpen`/`onClose` props and bound to the global modal store in
|
||||
* a SEPARATE file (controlled-modals/notifications). This component must NOT
|
||||
* import the modal store: the header imports `MODAL_NOTIFICATIONS_ID` from here,
|
||||
* and a store import would close the `modal-store → controlled-modals → modal →
|
||||
* store` cycle and trip a temporal-dead-zone error on the id (same reason the
|
||||
* mailbox-settings modal is split this way).
|
||||
*/
|
||||
export const ModalNotifications = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ModalNotificationsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const config = useConfig();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t("Notifications")}
|
||||
size={ModalSize.MEDIUM}
|
||||
>
|
||||
<div className="mailbox-settings__section">
|
||||
<p className="mailbox-settings__section-description">
|
||||
{t(
|
||||
"Devices where you receive push notifications. These are personal to you and span all your mailboxes.",
|
||||
)}
|
||||
</p>
|
||||
{config.PUSH_ENABLED ? (
|
||||
<UserDevicesGrid />
|
||||
) : (
|
||||
<p>{t("Notifications are not available on this server.")}</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+3
-24
@@ -1,7 +1,5 @@
|
||||
import { useUrlSearchParams } from "@/hooks/use-url-search-params";
|
||||
import { findRootFolder } from "../../mailbox-panel/components/mailbox-list";
|
||||
import { useLabelsList } from "@/features/api/gen";
|
||||
import type { TreeLabel } from "@/features/api/gen/models";
|
||||
import { useCurrentFolderName } from "@/hooks/use-current-folder-name";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -16,7 +14,7 @@ import useDeleteDrafts from "@/features/message/use-delete-drafts";
|
||||
import useStarred from "@/features/message/use-starred";
|
||||
import useCanEditThreads from "@/features/message/use-can-edit-threads";
|
||||
import { ThreadPanelFilter } from "./thread-panel-filter";
|
||||
import { THREAD_PANEL_FILTER_PARAMS, useThreadPanelFilters } from "../hooks/use-thread-panel-filters";
|
||||
import { useThreadPanelFilters } from "../hooks/use-thread-panel-filters";
|
||||
import { SelectionReadStatus, SelectionStarredStatus } from "@/features/providers/thread-selection";
|
||||
import { LabelsWidget } from "@/features/layouts/components/labels-widget";
|
||||
import useAbility, { Abilities } from "@/hooks/use-ability";
|
||||
@@ -46,7 +44,6 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
const searchParams = useUrlSearchParams();
|
||||
const isSearch = searchParams.has('search');
|
||||
const { threads, selectedMailbox, unselectThread } = useMailboxContext();
|
||||
const labelsQuery = useLabelsList({ mailbox_id: selectedMailbox?.id }, { query: { enabled: !!selectedMailbox && !!searchParams.get('label_slug') } })
|
||||
const isTrashedView = ViewHelper.isTrashedView();
|
||||
const isSpamView = ViewHelper.isSpamView();
|
||||
const isArchivedView = ViewHelper.isArchivedView();
|
||||
@@ -61,25 +58,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
// regardless.
|
||||
const canEditSelection = useCanEditThreads(selectedThreadIds);
|
||||
|
||||
const findLabelBySlug = (labels: readonly TreeLabel[], slug: string): TreeLabel | undefined => {
|
||||
for (const label of labels) {
|
||||
if (label.slug === slug) return label;
|
||||
const found = findLabelBySlug(label.children, slug);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (searchParams.has('search')) return t('folder.search', { defaultValue: 'Search' });
|
||||
if (searchParams.has('label_slug')) return findLabelBySlug(labelsQuery.data?.data || [], searchParams.get('label_slug')!)?.display_name;
|
||||
// Thread panel filters stack on top of the folder filter — strip them
|
||||
// so the matching resolves to the underlying folder.
|
||||
const folderParams = new URLSearchParams(searchParams.toString());
|
||||
THREAD_PANEL_FILTER_PARAMS.forEach((param) => folderParams.delete(param));
|
||||
const activeFolder = findRootFolder((folder) => new URLSearchParams(folder.filter).toString() === folderParams.toString());
|
||||
return activeFolder?.name ?? t('Messages');
|
||||
}, [searchParams, labelsQuery.data?.data, t, findLabelBySlug])
|
||||
const title = useCurrentFolderName() ?? t('Messages');
|
||||
|
||||
const handleSelectAllToggle = () => {
|
||||
if (isAllSelected) {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* The native push client is the iOS/Android counterpart of the Web Push flow:
|
||||
* these tests pin the registration contract (platform = transport, opt-in
|
||||
* marker gating, token-hash sign-out matching) and the tap deep-link mirroring
|
||||
* of the service worker's targetUrl.
|
||||
*/
|
||||
import { hashEndpoint, hasPushOptIn, setPushOptIn } from "@/features/push/shared";
|
||||
|
||||
// Partial mock: the api client pulled in through @/features/api/gen reaches
|
||||
// auth-session.ts, which needs the real registerPlugin.
|
||||
vi.mock("@capacitor/core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@capacitor/core")>()),
|
||||
Capacitor: { getPlatform: vi.fn(), isNativePlatform: vi.fn() },
|
||||
}));
|
||||
vi.mock("@capacitor/device", () => ({
|
||||
Device: { getInfo: vi.fn() },
|
||||
}));
|
||||
vi.mock("@capacitor/app", () => ({
|
||||
App: { getInfo: vi.fn() },
|
||||
}));
|
||||
vi.mock("@capacitor/push-notifications", () => ({
|
||||
PushNotifications: {
|
||||
addListener: vi.fn(),
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
checkPermissions: vi.fn(),
|
||||
requestPermissions: vi.fn(),
|
||||
removeAllDeliveredNotifications: vi.fn(),
|
||||
createChannel: vi.fn(),
|
||||
},
|
||||
}));
|
||||
// The Android channel name/description go through i18n at creation time; the
|
||||
// real instance would drag the http backend into the test environment.
|
||||
vi.mock("@/features/i18n/initI18n", () => ({
|
||||
default: { t: (key: string) => key },
|
||||
}));
|
||||
vi.mock("./platform", () => ({
|
||||
isNativePlatform: vi.fn(),
|
||||
}));
|
||||
// Repo pattern for the generated client: automock the resource submodule (the
|
||||
// index re-exports it, so push.ts sees the mock through "@/features/api/gen").
|
||||
vi.mock("@/features/api/gen/channels/channels");
|
||||
|
||||
import { App } from "@capacitor/app";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Device } from "@capacitor/device";
|
||||
import { PushNotifications } from "@capacitor/push-notifications";
|
||||
|
||||
import { usersMeChannelsCreate } from "@/features/api/gen";
|
||||
|
||||
import {
|
||||
ANDROID_NOTIFICATION_CHANNEL_ID,
|
||||
currentNativeTokenHash,
|
||||
enableNativePush,
|
||||
pushTargetUrl,
|
||||
refreshNativePushRegistration,
|
||||
unregisterIfCurrentDevice,
|
||||
} from "./push";
|
||||
|
||||
import { isNativePlatform } from "./platform";
|
||||
|
||||
const USER_ID = "11111111-2222-3333-4444-555555555555";
|
||||
const TOKEN = "apns-device-token-1";
|
||||
|
||||
const push = vi.mocked(PushNotifications);
|
||||
const isNative = vi.mocked(isNativePlatform);
|
||||
const getPlatform = vi.mocked(Capacitor.getPlatform);
|
||||
const createChannel = vi.mocked(usersMeChannelsCreate);
|
||||
|
||||
/** Event listeners registered by the module, replayable per test. */
|
||||
let listeners: Record<string, Array<(event: unknown) => void>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
listeners = {};
|
||||
|
||||
isNative.mockReturnValue(true);
|
||||
getPlatform.mockReturnValue("ios");
|
||||
// addListener is overloaded per event name; a single generic implementation
|
||||
// can't satisfy the union, hence the cast.
|
||||
push.addListener.mockImplementation(((
|
||||
event: string,
|
||||
cb: (event: unknown) => void,
|
||||
) => {
|
||||
(listeners[event] ??= []).push(cb);
|
||||
return Promise.resolve({ remove: vi.fn() });
|
||||
}) as unknown as typeof PushNotifications.addListener);
|
||||
// The OS answers the register() call through the "registration" event.
|
||||
push.register.mockImplementation(async () => {
|
||||
listeners["registration"]?.forEach((cb) => cb({ value: TOKEN }));
|
||||
});
|
||||
push.checkPermissions.mockResolvedValue({ receive: "prompt" });
|
||||
push.requestPermissions.mockResolvedValue({ receive: "granted" });
|
||||
vi.mocked(Device.getInfo).mockResolvedValue({
|
||||
name: "Mon iPhone",
|
||||
model: "iPhone15,3",
|
||||
} as Awaited<ReturnType<typeof Device.getInfo>>);
|
||||
vi.mocked(App.getInfo).mockResolvedValue({
|
||||
version: "1.2.0",
|
||||
} as Awaited<ReturnType<typeof App.getInfo>>);
|
||||
createChannel.mockResolvedValue({} as Awaited<ReturnType<typeof usersMeChannelsCreate>>);
|
||||
});
|
||||
|
||||
describe("enableNativePush", () => {
|
||||
it("registers the APNs token as a push channel and stores the opt-in", async () => {
|
||||
await expect(enableNativePush(USER_ID)).resolves.toBe("registered");
|
||||
|
||||
expect(createChannel).toHaveBeenCalledWith({
|
||||
type: "push",
|
||||
platform: "apns",
|
||||
token: TOKEN,
|
||||
name: "Mon iPhone",
|
||||
app_version: "1.2.0",
|
||||
});
|
||||
expect(hasPushOptIn(USER_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it("maps the Android shell to the fcm transport", async () => {
|
||||
getPlatform.mockReturnValue("android");
|
||||
await enableNativePush(USER_ID);
|
||||
expect(createChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ platform: "fcm" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the high-importance Android channel before registering", async () => {
|
||||
// Contract with the backend (FCM_ANDROID_CHANNEL_ID, asserted against the
|
||||
// same literal in test_push.py) and the manifest meta-data.
|
||||
expect(ANDROID_NOTIFICATION_CHANNEL_ID).toBe("new_messages");
|
||||
|
||||
getPlatform.mockReturnValue("android");
|
||||
await enableNativePush(USER_ID);
|
||||
expect(push.createChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: ANDROID_NOTIFICATION_CHANNEL_ID,
|
||||
importance: 4,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates no channel on iOS (no channel concept there)", async () => {
|
||||
await enableNativePush(USER_ID);
|
||||
expect(push.createChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns denied without registering when the permission is refused", async () => {
|
||||
push.requestPermissions.mockResolvedValue({ receive: "denied" });
|
||||
await expect(enableNativePush(USER_ID)).resolves.toBe("denied");
|
||||
expect(push.register).not.toHaveBeenCalled();
|
||||
expect(createChannel).not.toHaveBeenCalled();
|
||||
expect(hasPushOptIn(USER_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a registration failure without opting the user in", async () => {
|
||||
push.register.mockImplementation(async () => {
|
||||
listeners["registrationError"]?.forEach((cb) => cb({ error: "boom" }));
|
||||
});
|
||||
await expect(enableNativePush(USER_ID)).resolves.toBe(
|
||||
"registration_failed",
|
||||
);
|
||||
expect(hasPushOptIn(USER_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("is unsupported outside the native shell", async () => {
|
||||
isNative.mockReturnValue(false);
|
||||
await expect(enableNativePush(USER_ID)).resolves.toBe("unsupported");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshNativePushRegistration", () => {
|
||||
it("stays passive without the user's opt-in marker", async () => {
|
||||
push.checkPermissions.mockResolvedValue({ receive: "granted" });
|
||||
await refreshNativePushRegistration(USER_ID);
|
||||
expect(push.register).not.toHaveBeenCalled();
|
||||
expect(createChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never prompts: granted permission is required", async () => {
|
||||
setPushOptIn(USER_ID);
|
||||
push.checkPermissions.mockResolvedValue({ receive: "prompt" });
|
||||
await refreshNativePushRegistration(USER_ID);
|
||||
expect(push.requestPermissions).not.toHaveBeenCalled();
|
||||
expect(push.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-registers idempotently for an opted-in user", async () => {
|
||||
setPushOptIn(USER_ID);
|
||||
push.checkPermissions.mockResolvedValue({ receive: "granted" });
|
||||
await refreshNativePushRegistration(USER_ID);
|
||||
expect(createChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ token: TOKEN }),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-ensures the Android channel on refresh", async () => {
|
||||
// The channel must exist before the first killed-app push; re-creating is
|
||||
// idempotent and follows a language change with a localized rename.
|
||||
getPlatform.mockReturnValue("android");
|
||||
setPushOptIn(USER_ID);
|
||||
push.checkPermissions.mockResolvedValue({ receive: "granted" });
|
||||
await refreshNativePushRegistration(USER_ID);
|
||||
expect(push.createChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: ANDROID_NOTIFICATION_CHANNEL_ID }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unregisterIfCurrentDevice", () => {
|
||||
it("unregisters and clears the opt-in when the row is this device", async () => {
|
||||
await enableNativePush(USER_ID);
|
||||
await unregisterIfCurrentDevice(await hashEndpoint(TOKEN), USER_ID);
|
||||
expect(push.unregister).toHaveBeenCalled();
|
||||
expect(hasPushOptIn(USER_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops for a remote device row", async () => {
|
||||
await enableNativePush(USER_ID);
|
||||
await unregisterIfCurrentDevice(await hashEndpoint("other-token"), USER_ID);
|
||||
expect(push.unregister).not.toHaveBeenCalled();
|
||||
expect(hasPushOptIn(USER_ID)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentNativeTokenHash", () => {
|
||||
// The devices grid uses it to spot this device's row (hide "enable" when
|
||||
// already enrolled), so it must match the server-side token_hash exactly.
|
||||
it("matches the enrolled token's server hash", async () => {
|
||||
await enableNativePush(USER_ID);
|
||||
expect(await currentNativeTokenHash()).toBe(await hashEndpoint(TOKEN));
|
||||
});
|
||||
|
||||
it("is null before any registration", async () => {
|
||||
expect(await currentNativeTokenHash()).toBeNull();
|
||||
});
|
||||
|
||||
it("is null again after this device signs out", async () => {
|
||||
await enableNativePush(USER_ID);
|
||||
await unregisterIfCurrentDevice(await hashEndpoint(TOKEN), USER_ID);
|
||||
expect(await currentNativeTokenHash()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushTargetUrl", () => {
|
||||
// Mirrors targetUrl in public/sw.js — the two deep-link builders must route
|
||||
// a tap on the same payload to the same place.
|
||||
it("routes to the thread with the inbox filter and message anchor", () => {
|
||||
expect(
|
||||
pushTargetUrl({
|
||||
mailbox_id: "mb-1",
|
||||
thread_id: "th-1",
|
||||
message_id: "msg-1",
|
||||
}),
|
||||
).toBe("/mailbox/mb-1/thread/th-1?has_active=1#thread-message-msg-1");
|
||||
});
|
||||
|
||||
it("omits the anchor without a message id", () => {
|
||||
expect(pushTargetUrl({ mailbox_id: "mb-1", thread_id: "th-1" })).toBe(
|
||||
"/mailbox/mb-1/thread/th-1?has_active=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the root without routing ids", () => {
|
||||
expect(pushTargetUrl({ thread_id: "th-1" })).toBe("/");
|
||||
expect(pushTargetUrl(undefined)).toBe("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Native (Capacitor) push enable flow for the iOS / Android shells.
|
||||
*
|
||||
* Mirror of the Web Push client (`devices-view/web-push.ts`) on the native
|
||||
* transports: `@capacitor/push-notifications` yields the APNs (iOS) / FCM
|
||||
* (Android) device token and the app registers it as a user-scoped `push`
|
||||
* channel through the same `POST /users/me/channels/` upsert. Unlike Web Push
|
||||
* no VAPID key is involved — the OS plugins carry their own credentials (APNs
|
||||
* entitlement, bundled google-services.json). Token rotation is caught by the
|
||||
* idempotent on-launch re-registration (`refreshNativePushRegistration`), the
|
||||
* pattern docs/push-notifications.md §4 recommends over a long-lived
|
||||
* `registration` listener.
|
||||
*/
|
||||
import { App } from "@capacitor/app";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Device } from "@capacitor/device";
|
||||
import {
|
||||
ActionPerformed,
|
||||
PushNotifications,
|
||||
RegistrationError,
|
||||
Token,
|
||||
} from "@capacitor/push-notifications";
|
||||
|
||||
import {
|
||||
PlatformEnum,
|
||||
PushChannelCreateTypeEnum,
|
||||
usersMeChannelsCreate,
|
||||
} from "@/features/api/gen";
|
||||
import { APP_STORAGE_PREFIX } from "@/features/config/constants";
|
||||
import i18n from "@/features/i18n/initI18n";
|
||||
import {
|
||||
clearPushOptIn,
|
||||
hashEndpoint,
|
||||
hasPushOptIn,
|
||||
setPushOptIn,
|
||||
} from "@/features/push/shared";
|
||||
|
||||
import { isNativePlatform } from "./platform";
|
||||
|
||||
/** Last token registered from this device. A push token is a device-held
|
||||
* routing id, not a credential (see docs/push-notifications.md §9); it is kept
|
||||
* so device sign-out can match this device's server row (`token_hash`) without
|
||||
* re-driving the OS registration. */
|
||||
const NATIVE_TOKEN_KEY = `${APP_STORAGE_PREFIX}push-native-token`;
|
||||
|
||||
/** APNs/FCM answer registration over the network; leave room for a slow radio
|
||||
* before reporting the enable attempt as failed. */
|
||||
const REGISTRATION_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Android notification channel our pushes render on. Mirror of
|
||||
* FCM_ANDROID_CHANNEL_ID (core/services/push/fcm.py) and of the manifest's
|
||||
* default-channel meta-data — contract-tested on both sides. */
|
||||
export const ANDROID_NOTIFICATION_CHANNEL_ID = "new_messages";
|
||||
|
||||
/** Create the Android channel (idempotent) before the OS registration.
|
||||
*
|
||||
* Without it, Android 8+ renders FCM messages on the SDK's anonymous
|
||||
* "Miscellaneous" fallback at DEFAULT importance — no heads-up banner, and an
|
||||
* unnamed entry in the system notification settings — whatever the message
|
||||
* priority says. Importance is only honored at creation (re-calls can rename,
|
||||
* never upgrade), hence HIGH from the very first call. iOS has no channels.
|
||||
* Best-effort: delivery still works through the fallback channel if this
|
||||
* fails. */
|
||||
const ensureAndroidNotificationChannel = async (): Promise<void> => {
|
||||
if (Capacitor.getPlatform() !== "android") return;
|
||||
try {
|
||||
await PushNotifications.createChannel({
|
||||
id: ANDROID_NOTIFICATION_CHANNEL_ID,
|
||||
name: i18n.t("New messages"),
|
||||
description: i18n.t("Alerts for new messages in your mailboxes"),
|
||||
importance: 4, // IMPORTANCE_HIGH — heads-up banner
|
||||
});
|
||||
} catch {
|
||||
// Never block the registration flow on a cosmetic failure.
|
||||
}
|
||||
};
|
||||
|
||||
export type EnableNativePushResult =
|
||||
| "registered"
|
||||
| "denied" // permission refused — needs the OS app settings
|
||||
| "unsupported" // not running inside a native shell
|
||||
| "registration_failed"; // OS/gateway registration failed — retryable
|
||||
|
||||
/** `platform` is a transport, not an OS: the iOS shell registers the APNs
|
||||
* token, the Android shell the FCM token (docs/push-notifications.md §2). */
|
||||
const nativePlatform = (): PlatformEnum =>
|
||||
Capacitor.getPlatform() === "ios" ? PlatformEnum.apns : PlatformEnum.fcm;
|
||||
|
||||
/** Human device label for the settings list. Prefer the user-assigned device
|
||||
* name where the OS exposes it (recent iOS returns a generic "iPhone" without
|
||||
* a special entitlement), falling back to the hardware model. */
|
||||
const deviceLabel = async (): Promise<string | undefined> => {
|
||||
try {
|
||||
const info = await Device.getInfo();
|
||||
return info.name || info.model;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const appVersion = async (): Promise<string | undefined> => {
|
||||
try {
|
||||
return (await App.getInfo()).version;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Drive the OS registration and resolve with the device token. Listeners are
|
||||
* attached before `register()` so an immediately-emitted `registration` event
|
||||
* can't be missed; both are removed once settled. */
|
||||
const obtainToken = async (): Promise<string> => {
|
||||
let settle: (result: { token?: string; error?: string }) => void = () => {};
|
||||
const outcome = new Promise<{ token?: string; error?: string }>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
const handles = await Promise.all([
|
||||
PushNotifications.addListener("registration", (token: Token) =>
|
||||
settle({ token: token.value }),
|
||||
),
|
||||
PushNotifications.addListener(
|
||||
"registrationError",
|
||||
(event: RegistrationError) => settle({ error: event.error }),
|
||||
),
|
||||
]);
|
||||
const timeout = setTimeout(
|
||||
() => settle({ error: "timed out" }),
|
||||
REGISTRATION_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
await PushNotifications.register();
|
||||
const result = await outcome;
|
||||
if (!result.token) {
|
||||
throw new Error(`Push registration failed: ${result.error}`);
|
||||
}
|
||||
return result.token;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
handles.forEach((handle) => void handle.remove());
|
||||
}
|
||||
};
|
||||
|
||||
/** Upsert this device's token as the user's native `push` channel. The backend
|
||||
* keys on the token hash, so re-running is idempotent. */
|
||||
const registerNativeDevice = async (token: string): Promise<void> => {
|
||||
await usersMeChannelsCreate({
|
||||
type: PushChannelCreateTypeEnum.push,
|
||||
platform: nativePlatform(),
|
||||
token,
|
||||
name: await deviceLabel(),
|
||||
app_version: await appVersion(),
|
||||
});
|
||||
try {
|
||||
localStorage.setItem(NATIVE_TOKEN_KEY, token);
|
||||
} catch {
|
||||
// Storage unavailable: only device sign-out matching degrades.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the full enable flow: OS permission prompt (only when still undecided),
|
||||
* OS registration, channel upsert. Returns a discriminated outcome for every
|
||||
* expected non-success case so the caller can show an accurate message.
|
||||
*/
|
||||
export const enableNativePush = async (
|
||||
userId?: string,
|
||||
): Promise<EnableNativePushResult> => {
|
||||
if (!isNativePlatform()) {
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
let permission = await PushNotifications.checkPermissions();
|
||||
if (
|
||||
permission.receive === "prompt" ||
|
||||
permission.receive === "prompt-with-rationale"
|
||||
) {
|
||||
permission = await PushNotifications.requestPermissions();
|
||||
}
|
||||
if (permission.receive !== "granted") {
|
||||
// iOS only lets the app ask once; afterwards only the OS settings can
|
||||
// flip it, so "denied" tells the UI to point there.
|
||||
return "denied";
|
||||
}
|
||||
|
||||
await ensureAndroidNotificationChannel();
|
||||
|
||||
try {
|
||||
await registerNativeDevice(await obtainToken());
|
||||
} catch {
|
||||
return "registration_failed";
|
||||
}
|
||||
|
||||
// Remember this user's explicit opt-in on this device so a later login
|
||||
// (after a logout teardown) can re-register them automatically.
|
||||
setPushOptIn(userId);
|
||||
return "registered";
|
||||
};
|
||||
|
||||
/**
|
||||
* Idempotent on-launch re-registration (docs/push-notifications.md §4): catches
|
||||
* a silently rotated token and refreshes `last_used_at`. Passive — only for a
|
||||
* user who explicitly enabled push on this device (opt-in marker) and only when
|
||||
* the OS permission is already granted; never prompts. This is also what
|
||||
* recreates the server channel after a voluntary logout (deleted server-side)
|
||||
* on the same user's next login. Unlike the web flow there is no shared-device
|
||||
* teardown here: without the marker we simply stay passive, and a different
|
||||
* user who enables push triggers the server-side token reclaim instead.
|
||||
*/
|
||||
export const refreshNativePushRegistration = async (
|
||||
userId?: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (!isNativePlatform() || !hasPushOptIn(userId)) return;
|
||||
const permission = await PushNotifications.checkPermissions();
|
||||
if (permission.receive !== "granted") return;
|
||||
await ensureAndroidNotificationChannel();
|
||||
await registerNativeDevice(await obtainToken());
|
||||
} catch {
|
||||
// Best-effort refresh — never disrupt app load.
|
||||
}
|
||||
};
|
||||
|
||||
/** `token_hash` of this device's last registered token, or null before any
|
||||
* registration (or after a sign-out, which clears it). Matches the server
|
||||
* rows' `token_hash`, so the UI can recognise this device in the list (e.g.
|
||||
* hide "enable" when it is already enrolled). */
|
||||
export const currentNativeTokenHash = async (): Promise<string | null> => {
|
||||
if (!isNativePlatform()) return null;
|
||||
try {
|
||||
const token = localStorage.getItem(NATIVE_TOKEN_KEY);
|
||||
return token ? await hashEndpoint(token) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Native counterpart of `unsubscribeIfCurrentBrowser`: when the signed-out
|
||||
* device row is *this* device (`token_hash` match), stop OS delivery
|
||||
* (`unregister` drops the FCM token / APNs registration) and clear the opt-in
|
||||
* marker so the on-launch refresh doesn't silently re-register it. No-op for a
|
||||
* remote device. Best-effort — the caller DELETEs the server row regardless. */
|
||||
export const unregisterIfCurrentDevice = async (
|
||||
tokenHash: string | null | undefined,
|
||||
userId?: string,
|
||||
): Promise<void> => {
|
||||
if (!tokenHash || !isNativePlatform()) return;
|
||||
try {
|
||||
const token = localStorage.getItem(NATIVE_TOKEN_KEY);
|
||||
if (!token || (await hashEndpoint(token)) !== tokenHash) return;
|
||||
clearPushOptIn(userId);
|
||||
localStorage.removeItem(NATIVE_TOKEN_KEY);
|
||||
await PushNotifications.unregister();
|
||||
} catch {
|
||||
// Best-effort; sign-out proceeds regardless.
|
||||
}
|
||||
};
|
||||
|
||||
/** Deep-link target mirroring the Web Push service worker's `targetUrl`
|
||||
* (public/sw.js): thread view + `has_active` + optional message anchor. The
|
||||
* message goes in the hash because that is what the thread view scrolls to and
|
||||
* highlights ("#thread-message-{id}"); a query param would be dropped by the
|
||||
* threads-list allow-list. The payload carries content-free routing ids only
|
||||
* ({type, thread_id, message_id, mailbox_id, unread_count}); FCM stringifies
|
||||
* every value, so ids are always strings here. */
|
||||
export const pushTargetUrl = (data: unknown): string => {
|
||||
const payload = (data ?? {}) as Record<string, unknown>;
|
||||
const mailboxId = payload.mailbox_id;
|
||||
const threadId = payload.thread_id;
|
||||
if (
|
||||
typeof mailboxId !== "string" ||
|
||||
!mailboxId ||
|
||||
typeof threadId !== "string" ||
|
||||
!threadId
|
||||
) {
|
||||
return "/";
|
||||
}
|
||||
const messageId = payload.message_id;
|
||||
const hash =
|
||||
typeof messageId === "string" && messageId
|
||||
? `#thread-message-${messageId}`
|
||||
: "";
|
||||
return `/mailbox/${mailboxId}/thread/${threadId}?has_active=1${hash}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Route notification taps to the thread they point at. Registered once at
|
||||
* bootstrap — as early as possible so the tap that cold-started the app (the
|
||||
* plugin replays it to a freshly added listener) is not missed.
|
||||
*/
|
||||
export const listenForNativePushTaps = (
|
||||
navigate: (url: string) => void,
|
||||
): void => {
|
||||
if (!isNativePlatform()) return;
|
||||
void PushNotifications.addListener(
|
||||
"pushNotificationActionPerformed",
|
||||
(action: ActionPerformed) =>
|
||||
navigate(pushTargetUrl(action.notification.data)),
|
||||
);
|
||||
};
|
||||
|
||||
/** Dismiss delivered notifications once the user is actually looking at the
|
||||
* app; the foreground counterpart of the web `clearAppBadge` in features/auth
|
||||
* (the iOS badge itself is reset natively — see AppDelegate). */
|
||||
export const clearDeliveredNativeNotifications = (): void => {
|
||||
if (!isNativePlatform()) return;
|
||||
PushNotifications.removeAllDeliveredNotifications().catch(() => {});
|
||||
};
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useConfigRetrieve } from "@/features/api/gen";
|
||||
import { AppConfig, resolveConfig } from "@/features/config/resolve";
|
||||
import { Spinner } from "@gouvfr-lasuite/ui-kit";
|
||||
import { PropsWithChildren, createContext, useContext, useMemo } from "react";
|
||||
import {
|
||||
PropsWithChildren,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
const ConfigContext = createContext<AppConfig | undefined>(undefined)
|
||||
|
||||
|
||||
@@ -258,6 +258,11 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
|
||||
const mailboxQuery = useMailboxesList({
|
||||
query: {
|
||||
refetchInterval: 30 * 1000, // 30 seconds
|
||||
// React Query pauses `refetchInterval` on a hidden tab, which is
|
||||
// exactly when the unread badge (use-unread-badge) needs to spot
|
||||
// mail arriving. Web Push covers that window only for the few users
|
||||
// who opted in, so the poll has to keep running for everyone else.
|
||||
refetchIntervalInBackground: true,
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const FAVICON_SVG = `<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 40L16 30Z" fill="#2845C1"/>
|
||||
</svg>`;
|
||||
|
||||
const getFaviconLinks = () =>
|
||||
Array.from(document.head.querySelectorAll<HTMLLinkElement>('link[rel="icon"]'));
|
||||
|
||||
const decodeHref = (href: string) => decodeURIComponent(href.replace("data:image/svg+xml,", ""));
|
||||
|
||||
/** Let the fetch + parse chain behind `setFaviconBadge` settle. */
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("theme-favicons", () => {
|
||||
// The badged-href cache and the installed links live in module scope, so
|
||||
// every test re-imports the module to start from a clean slate.
|
||||
let favicons: typeof import("./theme-favicons");
|
||||
let cleanup: () => void;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve(new Response(FAVICON_SVG, { status: 200 }))),
|
||||
);
|
||||
favicons = await import("./theme-favicons");
|
||||
cleanup = favicons.installThemeFavicons("anct");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("installs one favicon per color scheme", () => {
|
||||
expect(getFaviconLinks().map((link) => [link.media, new URL(link.href).pathname])).toEqual([
|
||||
["(prefers-color-scheme: light)", "/images/anct/favicon-light.svg"],
|
||||
["(prefers-color-scheme: dark)", "/images/anct/favicon-dark.svg"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("badges every favicon variant with a dot in the bottom-right corner", async () => {
|
||||
favicons.setFaviconBadge(true);
|
||||
await flush();
|
||||
|
||||
for (const link of getFaviconLinks()) {
|
||||
const svg = decodeHref(link.href);
|
||||
expect(svg).toContain('fill="#D7010E"');
|
||||
// Fixed radius 9, centered 1.4 radii from the bottom-right corner.
|
||||
expect(svg).toContain('cx="35.4" cy="35.4" r="9"');
|
||||
// The glyph is punched out around the dot so it stays readable at 16px.
|
||||
expect(svg).toContain('mask="url(#favicon-unread-badge-cutout)"');
|
||||
expect(svg).toContain('fill="#2845C1"');
|
||||
}
|
||||
});
|
||||
|
||||
it("fetches each variant once across badge toggles", async () => {
|
||||
favicons.setFaviconBadge(true);
|
||||
await flush();
|
||||
favicons.setFaviconBadge(false);
|
||||
favicons.setFaviconBadge(true);
|
||||
await flush();
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("restores the plain favicon when the badge is cleared", async () => {
|
||||
favicons.setFaviconBadge(true);
|
||||
await flush();
|
||||
favicons.setFaviconBadge(false);
|
||||
|
||||
expect(getFaviconLinks().map((link) => new URL(link.href).pathname)).toEqual([
|
||||
"/images/anct/favicon-light.svg",
|
||||
"/images/anct/favicon-dark.svg",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the plain favicon when the source SVG cannot be fetched", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("offline"))));
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
favicons.setFaviconBadge(true);
|
||||
await flush();
|
||||
|
||||
expect(getFaviconLinks().every((link) => link.href.endsWith(".svg"))).toBe(true);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,118 @@
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const BADGE_COLOR = "#D7010E"; // --c--globals--colors--error-550
|
||||
const BADGE_MASK_ID = "favicon-unread-badge-cutout";
|
||||
|
||||
type FaviconLink = { el: HTMLLinkElement; baseHref: string };
|
||||
|
||||
let links: FaviconLink[] = [];
|
||||
let badgeEnabled = false;
|
||||
const badgedHrefs = new Map<string, Promise<string>>();
|
||||
|
||||
type ViewBox = { minX: number; minY: number; width: number; height: number };
|
||||
|
||||
/** Keep the generated coordinates readable: 1/100 of a viewBox unit is far
|
||||
* below what a 16px favicon can resolve. */
|
||||
const round = (value: number) => String(Math.round(value * 100) / 100);
|
||||
|
||||
const readViewBox = (svg: Element): ViewBox => {
|
||||
const values = (svg.getAttribute("viewBox") ?? "")
|
||||
.split(/[\s,]+/)
|
||||
.map(Number)
|
||||
.filter((value) => Number.isFinite(value));
|
||||
if (values.length !== 4 || values[2] <= 0 || values[3] <= 0) {
|
||||
throw new Error("Favicon SVG has no usable viewBox");
|
||||
}
|
||||
return { minX: values[0], minY: values[1], width: values[2], height: values[3] };
|
||||
};
|
||||
|
||||
/**
|
||||
* Add an unread dot to a theme favicon: the glyph is punched out around the
|
||||
* dot so it stays readable at 16px, where a flat overlay would blend into the
|
||||
* artwork below it.
|
||||
*/
|
||||
const buildBadgedSvg = (source: string): string => {
|
||||
const doc = new DOMParser().parseFromString(source, "image/svg+xml");
|
||||
const svg = doc.documentElement;
|
||||
if (svg.tagName !== "svg" || doc.querySelector("parsererror")) {
|
||||
throw new Error("Favicon source is not a valid SVG");
|
||||
}
|
||||
|
||||
const { minX, minY, width, height } = readViewBox(svg);
|
||||
const radius = 9;
|
||||
const cx = minX + width - radius * 1.4;
|
||||
const cy = minY + height - radius * 1.4;
|
||||
|
||||
const mask = doc.createElementNS(SVG_NS, "mask");
|
||||
mask.setAttribute("id", BADGE_MASK_ID);
|
||||
mask.setAttribute("maskUnits", "userSpaceOnUse");
|
||||
mask.setAttribute("x", round(minX));
|
||||
mask.setAttribute("y", round(minY));
|
||||
mask.setAttribute("width", round(width));
|
||||
mask.setAttribute("height", round(height));
|
||||
|
||||
const maskFill = doc.createElementNS(SVG_NS, "rect");
|
||||
maskFill.setAttribute("x", round(minX));
|
||||
maskFill.setAttribute("y", round(minY));
|
||||
maskFill.setAttribute("width", round(width));
|
||||
maskFill.setAttribute("height", round(height));
|
||||
maskFill.setAttribute("fill", "white");
|
||||
|
||||
const maskHole = doc.createElementNS(SVG_NS, "circle");
|
||||
maskHole.setAttribute("cx", round(cx));
|
||||
maskHole.setAttribute("cy", round(cy));
|
||||
maskHole.setAttribute("r", round(radius * 1.5));
|
||||
maskHole.setAttribute("fill", "black");
|
||||
|
||||
mask.append(maskFill, maskHole);
|
||||
|
||||
const masked = doc.createElementNS(SVG_NS, "g");
|
||||
masked.setAttribute("mask", `url(#${BADGE_MASK_ID})`);
|
||||
masked.append(...Array.from(svg.childNodes));
|
||||
|
||||
const badge = doc.createElementNS(SVG_NS, "circle");
|
||||
badge.setAttribute("cx", round(cx));
|
||||
badge.setAttribute("cy", round(cy));
|
||||
badge.setAttribute("r", round(radius));
|
||||
badge.setAttribute("fill", BADGE_COLOR);
|
||||
|
||||
svg.append(mask, masked, badge);
|
||||
|
||||
return new XMLSerializer().serializeToString(svg);
|
||||
};
|
||||
|
||||
const getBadgedHref = (baseHref: string): Promise<string> => {
|
||||
let pending = badgedHrefs.get(baseHref);
|
||||
if (!pending) {
|
||||
pending = fetch(baseHref)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.text();
|
||||
})
|
||||
.then((source) => `data:image/svg+xml,${encodeURIComponent(buildBadgedSvg(source))}`)
|
||||
.catch((error) => {
|
||||
// Keep the plain favicon rather than blanking the tab icon, and drop
|
||||
// the entry so the next toggle retries instead of caching the failure.
|
||||
badgedHrefs.delete(baseHref);
|
||||
console.error("[favicon] Failed to build the unread badge.", error);
|
||||
return baseHref;
|
||||
});
|
||||
badgedHrefs.set(baseHref, pending);
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
|
||||
const applyBadge = (link: FaviconLink) => {
|
||||
if (!badgeEnabled) {
|
||||
link.el.href = link.baseHref;
|
||||
return;
|
||||
}
|
||||
void getBadgedHref(link.baseHref).then((href) => {
|
||||
// The badge may have been cleared, or the links reinstalled, while the
|
||||
// source SVG was in flight.
|
||||
if (badgeEnabled && link.el.isConnected) link.el.href = href;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Inject theme-aware SVG favicons into <head>. `index.html` only ships the
|
||||
* fixed PWA bitmap icons, which are not theme-aware. Called during bootstrap
|
||||
@@ -8,14 +123,37 @@ export const installThemeFavicons = (theme: string) => {
|
||||
{ media: "(prefers-color-scheme: light)", href: `/images/${theme}/favicon-light.svg` },
|
||||
{ media: "(prefers-color-scheme: dark)", href: `/images/${theme}/favicon-dark.svg` },
|
||||
];
|
||||
const links = variants.map(({ media, href }) => {
|
||||
links = variants.map(({ media, href }) => {
|
||||
const el = document.createElement("link");
|
||||
el.rel = "icon";
|
||||
el.type = "image/svg+xml";
|
||||
el.media = media;
|
||||
el.href = href;
|
||||
document.head.appendChild(el);
|
||||
return el;
|
||||
return { el, baseHref: href };
|
||||
});
|
||||
return () => links.forEach((el) => el.remove());
|
||||
links.forEach(applyBadge);
|
||||
return () => {
|
||||
links.forEach(({ el }) => el.remove());
|
||||
links = [];
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the unread dot on the favicons installed by `installThemeFavicons`.
|
||||
*
|
||||
* Silently a no-op on WebKit (Safari, and every iOS browser): it reads the
|
||||
* favicon once at first paint and never re-reads it, so *no* DOM change reaches
|
||||
* the tab — not an href mutation, a link remove+append, an SVG, a data: URI, or
|
||||
* a canvas-rasterised PNG. Verified on Safari 26.5, where even the canonical
|
||||
* dynamic-favicon demo (mathiasbynens.be/demo/dynamic-favicons) stays frozen, so
|
||||
* this is the engine and not our markup. Safari 26 did add the SVG favicon
|
||||
* *format*, which is a separate thing and easy to mistake for a fix. The unread
|
||||
* signal is carried in the tab title there instead — see `unread-badge.ts`.
|
||||
* The theme favicons themselves are unaffected: `installThemeFavicons` runs
|
||||
* before the first paint, which is the one moment WebKit does read them.
|
||||
*/
|
||||
export const setFaviconBadge = (enabled: boolean) => {
|
||||
badgeEnabled = enabled;
|
||||
links.forEach(applyBadge);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/features/providers/theme-favicons", () => ({
|
||||
setFaviconBadge: vi.fn(),
|
||||
}));
|
||||
|
||||
const SAFARI_UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15";
|
||||
const CHROME_UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
|
||||
const IOS_CHROME_UA =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/143.0.0.0 Mobile/15E148 Safari/604.1";
|
||||
const FIREFOX_UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0";
|
||||
|
||||
const stubUserAgent = (userAgent: string) =>
|
||||
vi.stubGlobal("navigator", { userAgent });
|
||||
|
||||
describe("unread-badge", () => {
|
||||
// The badge state lives in module scope, so every test re-imports the
|
||||
// module to start from a clean slate.
|
||||
let badge: typeof import("./unread-badge");
|
||||
let themeFavicons: typeof import("./theme-favicons");
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
badge = await import("./unread-badge");
|
||||
themeFavicons = await import("./theme-favicons");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("drives the favicon dot and notifies subscribers", () => {
|
||||
const listener = vi.fn();
|
||||
badge.subscribeUnreadBadge(listener);
|
||||
|
||||
badge.setUnreadBadge(true);
|
||||
|
||||
expect(themeFavicons.setFaviconBadge).toHaveBeenCalledWith(true);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(badge.getUnreadBadge()).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores a write that does not change the state", () => {
|
||||
const listener = vi.fn();
|
||||
badge.subscribeUnreadBadge(listener);
|
||||
|
||||
badge.setUnreadBadge(true);
|
||||
badge.setUnreadBadge(true);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops notifying an unsubscribed listener", () => {
|
||||
const listener = vi.fn();
|
||||
badge.subscribeUnreadBadge(listener)();
|
||||
|
||||
badge.setUnreadBadge(true);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// WebKit never re-reads the favicon after first paint, so the title is the
|
||||
// only surface left to carry the badge there. Everywhere else the favicon
|
||||
// dot does it, and a title marker would only duplicate it.
|
||||
it.each([
|
||||
["Safari", SAFARI_UA, "• "],
|
||||
["iOS Chrome (WebKit underneath)", IOS_CHROME_UA, "• "],
|
||||
["Chrome", CHROME_UA, ""],
|
||||
["Firefox", FIREFOX_UA, ""],
|
||||
])("marks the title on %s", (_name, userAgent, expected) => {
|
||||
stubUserAgent(userAgent);
|
||||
|
||||
expect(badge.unreadTitlePrefix(true)).toBe(expected);
|
||||
});
|
||||
|
||||
it("never marks the title while the badge is down", () => {
|
||||
stubUserAgent(SAFARI_UA);
|
||||
|
||||
expect(badge.unreadTitlePrefix(false)).toBe("");
|
||||
});
|
||||
|
||||
describe("trackUnreadTotal", () => {
|
||||
it("captures the total and clears while the tab is visible", () => {
|
||||
expect(badge.trackUnreadTotal(undefined, 5, false)).toEqual({
|
||||
baseline: 5,
|
||||
badge: false,
|
||||
});
|
||||
expect(badge.trackUnreadTotal(2, 5, false)).toEqual({
|
||||
baseline: 5,
|
||||
badge: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a first total on a hidden tab as a starting point, not an arrival", () => {
|
||||
expect(badge.trackUnreadTotal(undefined, 5, true)).toEqual({
|
||||
baseline: 5,
|
||||
badge: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises on a rise above the baseline while hidden", () => {
|
||||
expect(badge.trackUnreadTotal(5, 6, true)).toEqual({
|
||||
baseline: 5,
|
||||
badge: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the badge alone while hidden without a rise", () => {
|
||||
expect(badge.trackUnreadTotal(5, 5, true)).toEqual({ baseline: 5 });
|
||||
});
|
||||
|
||||
it("follows decreases while hidden so mail read elsewhere doesn't absorb the next arrival", () => {
|
||||
// Away from the tab, the 5 unread get read on the phone…
|
||||
const afterReads = badge.trackUnreadTotal(5, 0, true);
|
||||
expect(afterReads).toEqual({ baseline: 0 });
|
||||
// …then one new mail arrives: it must still raise the badge.
|
||||
expect(badge.trackUnreadTotal(afterReads.baseline, 1, true)).toEqual({
|
||||
baseline: 0,
|
||||
badge: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import { setFaviconBadge } from "@/features/providers/theme-favicons";
|
||||
|
||||
/** The marker the tab title carries where the favicon dot cannot render. A dot
|
||||
* rather than a count: it means "mail arrived while you were away", not "you
|
||||
* have N unread" — see `useUnreadBadge` for why the app draws that line. */
|
||||
const TITLE_PREFIX = "• ";
|
||||
|
||||
let badged = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* Raise or clear the unread badge: the single writer behind both of its
|
||||
* renderings — the favicon dot and the title marker — so the two can never
|
||||
* drift apart.
|
||||
*/
|
||||
export const setUnreadBadge = (enabled: boolean): void => {
|
||||
if (badged === enabled) return;
|
||||
badged = enabled;
|
||||
setFaviconBadge(enabled);
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const subscribeUnreadBadge = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const getUnreadBadge = (): boolean => badged;
|
||||
|
||||
export type UnreadTracking = {
|
||||
baseline: number;
|
||||
/** Raise (true), clear (false), or leave the badge untouched (absent). */
|
||||
badge?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide what a fresh unread total means for the badge (the pure core of
|
||||
* `useUnreadBadge`, extracted for testability).
|
||||
*
|
||||
* Visible tab — or the very first total on a hidden one: the total becomes the
|
||||
* baseline and the badge clears; the user is (or was just) looking. Hidden
|
||||
* tab: only a rise above the baseline raises the badge, and the baseline
|
||||
* follows decreases — mail read on another device must not absorb the next
|
||||
* arrival — without ever lowering the badge itself.
|
||||
*/
|
||||
export const trackUnreadTotal = (
|
||||
baseline: number | undefined,
|
||||
unreadTotal: number,
|
||||
hidden: boolean,
|
||||
): UnreadTracking => {
|
||||
if (hidden && baseline !== undefined) {
|
||||
const lowered = Math.min(baseline, unreadTotal);
|
||||
return unreadTotal > lowered
|
||||
? { baseline: lowered, badge: true }
|
||||
: { baseline: lowered };
|
||||
}
|
||||
return { baseline: unreadTotal, badge: false };
|
||||
};
|
||||
|
||||
/**
|
||||
* True on WebKit — Safari, and every iOS browser, which all wrap it.
|
||||
*
|
||||
* WebKit reads the favicon once at first paint and never re-reads it, so the
|
||||
* favicon dot never reaches the tab there (see `setFaviconBadge`); the title is
|
||||
* the only surface left to carry the signal. Chromium and Gecko do re-read it,
|
||||
* so they show the dot and their title stays clean.
|
||||
*
|
||||
* Sniffing the UA is unavoidable here: "does this engine re-read the favicon?"
|
||||
* has no feature test. Chromium ships both "Chrome"/"Edg" and "AppleWebKit",
|
||||
* hence the exclusion; iOS Chrome ("CriOS") and iOS Firefox ("FxiOS") ship
|
||||
* neither and are deliberately caught — they are WebKit underneath and share
|
||||
* the limitation.
|
||||
*/
|
||||
const isWebKit = (): boolean => {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const ua = navigator.userAgent;
|
||||
return /AppleWebKit/.test(ua) && !/Chrome|Chromium|Edg\//.test(ua);
|
||||
};
|
||||
|
||||
/** The tab title's unread marker, or "" where the favicon dot carries it. */
|
||||
export const unreadTitlePrefix = (enabled: boolean): string =>
|
||||
enabled && isWebKit() ? TITLE_PREFIX : "";
|
||||
|
||||
/** Live `unreadTitlePrefix`, for `useDocumentTitle`. */
|
||||
export const useUnreadTitlePrefix = (): string =>
|
||||
unreadTitlePrefix(useSyncExternalStore(subscribeUnreadBadge, getUnreadBadge));
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import {
|
||||
setUnreadBadge,
|
||||
trackUnreadTotal,
|
||||
} from "@/features/providers/unread-badge";
|
||||
import { listenForPushReceived } from "@/features/push/shared";
|
||||
|
||||
/**
|
||||
* Badge the tab when mail arrives while it sits in the background, and clear
|
||||
* the badge as soon as the user looks at the tab again. The badge renders as a
|
||||
* favicon dot, or as a title marker on engines whose favicon can't be updated
|
||||
* after load — see `unread-badge.ts`.
|
||||
*
|
||||
* The badge answers "something arrived while you were away", not "you have
|
||||
* unread mail": badging on `count_unread_threads > 0` leaves it lit forever for
|
||||
* anyone who keeps old unread threads around, which makes it worthless as a
|
||||
* signal. So arrivals are read as a rise of the unread total above a baseline:
|
||||
* the total the last time the tab was visible, lowered to follow the total
|
||||
* while the tab is hidden — mail read on another device must not absorb the
|
||||
* next arrival. It counts every mailbox the user can access — the badge
|
||||
* belongs to the tab, not to the selected mailbox.
|
||||
*
|
||||
* Two signals raise it. The mailbox poll works for every user but lags by up to
|
||||
* its interval; a Web Push raises it at once, and only where the user opted in
|
||||
* and a service worker runs. Neither ever lowers it: only the user coming back
|
||||
* to the tab does.
|
||||
*/
|
||||
export const useUnreadBadge = () => {
|
||||
const { mailboxes } = useMailboxContext();
|
||||
const unreadTotal = mailboxes?.reduce(
|
||||
(total, mailbox) => total + mailbox.count_unread_threads,
|
||||
0,
|
||||
);
|
||||
/** Unread total the last time the tab was visible. Stays `undefined` until
|
||||
* the first mailbox load, so a first fetch landing on an already-hidden tab
|
||||
* reads as a starting point rather than as an arrival. */
|
||||
const baseline = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (unreadTotal === undefined) return;
|
||||
const next = trackUnreadTotal(
|
||||
baseline.current,
|
||||
unreadTotal,
|
||||
document.hidden,
|
||||
);
|
||||
baseline.current = next.baseline;
|
||||
if (next.badge !== undefined) setUnreadBadge(next.badge);
|
||||
}, [unreadTotal]);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibilityChange = () => {
|
||||
if (document.hidden) return;
|
||||
baseline.current = unreadTotal;
|
||||
setUnreadBadge(false);
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
}, [unreadTotal]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
listenForPushReceived(() => {
|
||||
if (document.hidden) setUnreadBadge(true);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => () => setUnreadBadge(false), []);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { hashEndpoint, listenForPushReceived } from "./shared";
|
||||
|
||||
describe("push/shared", () => {
|
||||
describe("listenForPushReceived", () => {
|
||||
/** Stand-in for `navigator.serviceWorker`, which jsdom does not implement.
|
||||
* An EventTarget is enough: the helper only add/removeEventListener's on it
|
||||
* and reads `event.data`. */
|
||||
const withServiceWorker = () => {
|
||||
const target = new EventTarget();
|
||||
vi.stubGlobal("navigator", { serviceWorker: target });
|
||||
return (data: unknown) =>
|
||||
target.dispatchEvent(Object.assign(new Event("message"), { data }));
|
||||
};
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("fires on the worker's push notice", () => {
|
||||
const post = withServiceWorker();
|
||||
const onPush = vi.fn();
|
||||
|
||||
listenForPushReceived(onPush);
|
||||
post({ type: "push-received" });
|
||||
|
||||
expect(onPush).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("ignores other worker messages", () => {
|
||||
const post = withServiceWorker();
|
||||
const onPush = vi.fn();
|
||||
|
||||
listenForPushReceived(onPush);
|
||||
// The worker posts `push-subscription-changed` on the same channel.
|
||||
post({ type: "push-subscription-changed" });
|
||||
post("unstructured");
|
||||
post(null);
|
||||
|
||||
expect(onPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops firing once cleaned up", () => {
|
||||
const post = withServiceWorker();
|
||||
const onPush = vi.fn();
|
||||
|
||||
listenForPushReceived(onPush)();
|
||||
post({ type: "push-received" });
|
||||
|
||||
expect(onPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Native shells and older browsers have no service worker; callers rely on
|
||||
// this being a silent no-op rather than a throw at bootstrap.
|
||||
it("no-ops without a service worker", () => {
|
||||
vi.stubGlobal("navigator", {});
|
||||
expect(() => listenForPushReceived(vi.fn())()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashEndpoint", () => {
|
||||
// Shared contract vector: the backend computes the SAME value for a push
|
||||
// channel's `lookup_hash`/`token_hash` (`_token_hash` in
|
||||
// core/services/push/common.py, asserted against this exact literal in
|
||||
// test_push.py). The two implementations must stay byte-identical — the app
|
||||
// matches THIS device's registration to a server-listed device row purely
|
||||
// by comparing these hashes, so any drift silently breaks device sign-out
|
||||
// and shared-computer takeover detection.
|
||||
it("matches the backend token-hash contract vector", async () => {
|
||||
await expect(hashEndpoint("https://push.example/ep-123")).resolves.toBe(
|
||||
"aa90f805f294edd82e4284a23521c8b0067582a63c70fb030ddc77214bf8cf7b",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns lowercase sha256 hex (64 chars)", async () => {
|
||||
const hash = await hashEndpoint("https://push.example/other");
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Push client helpers shared by the two device-registration flows: Web Push
|
||||
* (`features/layouts/components/mailbox-settings/devices-view/web-push.ts`)
|
||||
* and the native Capacitor shells (`features/native/push.ts`). Both register
|
||||
* through the same `POST /users/me/channels/` upsert; these helpers keep the
|
||||
* opt-in semantics and the token-hash contract identical across transports.
|
||||
*/
|
||||
import { APP_STORAGE_PREFIX } from "@/features/config/constants";
|
||||
|
||||
/** Per-(device, user) opt-in marker. The push registration (browser
|
||||
* subscription or native OS token) and localStorage are per-device, *not*
|
||||
* per-user, so this flag is what tells "this user enabled push on this device"
|
||||
* apart from "a registration merely exists" (e.g. one left behind by a
|
||||
* previous user on a shared device). It gates the on-load (re-)registration:
|
||||
* only a user who explicitly enabled push here gets their server channel
|
||||
* silently (re)created on login — a voluntary logout deletes that channel
|
||||
* server-side, and this marker is what makes the same user's notifications
|
||||
* resume transparently while never enrolling a different user on the same
|
||||
* device. */
|
||||
const pushOptInKey = (userId: string): string =>
|
||||
`${APP_STORAGE_PREFIX}push-opt-in.${userId}`;
|
||||
|
||||
export const setPushOptIn = (userId: string | undefined): void => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
localStorage.setItem(pushOptInKey(userId), "1");
|
||||
} catch {
|
||||
// Private mode / storage disabled: the user just re-enables manually.
|
||||
}
|
||||
};
|
||||
|
||||
export const clearPushOptIn = (userId: string | undefined): void => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
localStorage.removeItem(pushOptInKey(userId));
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
};
|
||||
|
||||
export const hasPushOptIn = (userId: string | undefined): boolean => {
|
||||
if (!userId) return false;
|
||||
try {
|
||||
return localStorage.getItem(pushOptInKey(userId)) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** SHA-256 hex of a push token, byte-for-byte identical to the backend's
|
||||
* `Channel.lookup_hash` (`sha256("push:" + token).hexdigest()`, see
|
||||
* `_token_hash`). The `push:` prefix namespaces the input so the globally-unique
|
||||
* hash can't collide with another channel type — the frontend must mirror it
|
||||
* exactly. Lets the app match *this* device's registration (Web Push endpoint
|
||||
* or native token) to a server-listed device row (which exposes only the hash,
|
||||
* never the token). */
|
||||
export const hashEndpoint = async (endpoint: string): Promise<string> => {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(`push:${endpoint}`),
|
||||
);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
};
|
||||
|
||||
/** Mirror of `PUSH_RECEIVED` in `public/sw.js`: the content-free notice the
|
||||
* worker posts to open tabs on every push it handles. */
|
||||
const PUSH_RECEIVED = "push-received";
|
||||
|
||||
/**
|
||||
* Subscribe to the worker's "a push just arrived" notice, and return a cleanup
|
||||
* that removes the listener.
|
||||
*
|
||||
* Only Web Push routes through a service worker, so this is a no-op on the
|
||||
* native shells and wherever service workers are unavailable — callers must
|
||||
* treat it as an accelerator on top of whatever they already poll, never as the
|
||||
* only way they learn about new mail.
|
||||
*/
|
||||
export const listenForPushReceived = (onPush: () => void): (() => void) => {
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return () => {};
|
||||
}
|
||||
const handler = (event: MessageEvent): void => {
|
||||
const data = event.data as { type?: unknown } | null | undefined;
|
||||
if (data?.type === PUSH_RECEIVED) onPush();
|
||||
};
|
||||
navigator.serviceWorker.addEventListener("message", handler);
|
||||
return () => navigator.serviceWorker.removeEventListener("message", handler);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useLabelsList } from "@/features/api/gen";
|
||||
import type { TreeLabel } from "@/features/api/gen/models";
|
||||
import { findRootFolder } from "@/features/layouts/components/mailbox-panel/components/mailbox-list";
|
||||
import { THREAD_PANEL_FILTER_PARAMS } from "@/features/layouts/components/thread-panel/hooks/use-thread-panel-filters";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { useUrlSearchParams } from "@/hooks/use-url-search-params";
|
||||
|
||||
const findLabelBySlug = (labels: readonly TreeLabel[], slug: string): TreeLabel | undefined => {
|
||||
for (const label of labels) {
|
||||
if (label.slug === slug) return label;
|
||||
const found = findLabelBySlug(label.children, slug);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Name of the mailbox view the current url points to: a search, a label or a
|
||||
* folder.
|
||||
*
|
||||
* @returns The view name, or undefined while it cannot be resolved yet (label
|
||||
* still loading) or when the url filters match no known folder.
|
||||
*/
|
||||
export const useCurrentFolderName = (): string | undefined => {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useUrlSearchParams();
|
||||
const { selectedMailbox } = useMailboxContext();
|
||||
const labelSlug = searchParams.get('label_slug');
|
||||
const labelsQuery = useLabelsList(
|
||||
{ mailbox_id: selectedMailbox?.id },
|
||||
{ query: { enabled: !!selectedMailbox && !!labelSlug } }
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (searchParams.has('search')) return t('folder.search', { defaultValue: 'Search' });
|
||||
if (labelSlug) return findLabelBySlug(labelsQuery.data?.data || [], labelSlug)?.display_name;
|
||||
// Thread panel filters stack on top of the folder filter — strip them
|
||||
// so the matching resolves to the underlying folder.
|
||||
const folderParams = new URLSearchParams(searchParams.toString());
|
||||
THREAD_PANEL_FILTER_PARAMS.forEach((param) => folderParams.delete(param));
|
||||
const activeFolder = findRootFolder((folder) => new URLSearchParams(folder.filter).toString() === folderParams.toString());
|
||||
return activeFolder?.name;
|
||||
}, [searchParams, labelSlug, labelsQuery.data?.data, t]);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useUnreadTitlePrefix } from "@/features/providers/unread-badge";
|
||||
|
||||
const TITLE_SEPARATOR = " | ";
|
||||
|
||||
/**
|
||||
* Sets the document title to "<app name> | <segment> | …" for the current view.
|
||||
* Unresolved segments (a folder name still loading, no mailbox selected…) are
|
||||
* dropped so the title never shows a dangling separator.
|
||||
*
|
||||
* The title also carries the unread badge on engines whose favicon cannot (see
|
||||
* `unread-badge.ts`). It has to be applied here rather than by the badge itself:
|
||||
* this hook rewrites `document.title` on every route change, so a prefix set
|
||||
* from the outside would be wiped on the next navigation.
|
||||
*
|
||||
* @param segments - View segments, from the most generic to the most specific.
|
||||
*/
|
||||
export const useDocumentTitle = (
|
||||
...segments: (string | undefined | null | false)[]
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const prefix = useUnreadTitlePrefix();
|
||||
const title = [
|
||||
t("Messaging"),
|
||||
...segments.filter((segment): segment is string => !!segment),
|
||||
].join(TITLE_SEPARATOR);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = prefix + title;
|
||||
}, [prefix, title]);
|
||||
};
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { queryClient } from "@/features/api/query-client";
|
||||
import { Auth } from "@/features/auth";
|
||||
@@ -11,13 +9,10 @@ import { ConfigProvider } from "@/features/providers/config";
|
||||
import ErrorBoundary from "@/features/errors/error-boundary";
|
||||
import ThemeProvider from "@/features/providers/theme";
|
||||
|
||||
// Each route owns its document title through `useDocumentTitle`: a title set
|
||||
// here would run after the routes' effects (React runs child effects first)
|
||||
// and always override them.
|
||||
const RootShell = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("Messaging");
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
||||
|
||||
@@ -10,12 +10,15 @@ import { LeftPanel } from "@/features/layouts/components/main/left-panel";
|
||||
import { SKIP_LINK_TARGET_ID } from "@/features/ui/components/skip-link";
|
||||
import { FeedbackWidget } from "@/features/ui/components/feedback-widget";
|
||||
import { useTheme } from "@/features/providers/theme";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
const HomePage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme, variant, themeConfig } = useTheme();
|
||||
const { user } = useAuth();
|
||||
|
||||
useDocumentTitle();
|
||||
|
||||
if (user) {
|
||||
return <MainLayout />;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useThreadSelection } from "@/features/providers/thread-selection";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { useUrlSearchParams } from "@/hooks/use-url-search-params";
|
||||
import useAbility, { Abilities } from "@/hooks/use-ability";
|
||||
import { useCurrentFolderName } from "@/hooks/use-current-folder-name";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import ViewHelper from "@/features/utils/view-helper";
|
||||
import { useOpenImporter } from "@/features/layouts/components/mailbox-settings/imports-view/use-open-importer";
|
||||
|
||||
@@ -18,6 +20,7 @@ const Mailbox = () => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedMailbox, threads } = useMailboxContext();
|
||||
const openImporter = useOpenImporter();
|
||||
const folderName = useCurrentFolderName();
|
||||
const canImportMessages = useAbility(Abilities.CAN_IMPORT_MESSAGES, selectedMailbox);
|
||||
const { selectedThreadIds } = useThreadSelection();
|
||||
const searchParams = useUrlSearchParams();
|
||||
@@ -30,6 +33,8 @@ const Mailbox = () => {
|
||||
storage: localStorage,
|
||||
});
|
||||
|
||||
useDocumentTitle(selectedMailbox?.email, folderName);
|
||||
|
||||
const showImportButton = useMemo(() => {
|
||||
if (!canImportMessages || !emptyMailbox) return false;
|
||||
if (ViewHelper.isInboxView() || ViewHelper.isAllMessagesView()) return true;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MessageForm } from "@/features/forms/components/message-form";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/components/mailbox-list";
|
||||
import { SKIP_LINK_TARGET_ID } from "@/features/ui/components/skip-link";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
const NewMessageFormPage = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,6 +14,8 @@ const NewMessageFormPage = () => {
|
||||
const router = useRouter();
|
||||
const { queryStates, selectedMailbox } = useMailboxContext();
|
||||
|
||||
useDocumentTitle(t("New message"), selectedMailbox?.email);
|
||||
|
||||
const handleClose = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.history.back();
|
||||
|
||||
@@ -4,15 +4,22 @@ import { Panel, Group, Separator, useDefaultLayout } from "react-resizable-panel
|
||||
import { ThreadPanel } from "@/features/layouts/components/thread-panel";
|
||||
import { ThreadSelectionPlaceholder } from "@/features/layouts/components/thread-selection-placeholder";
|
||||
import { ThreadView } from "@/features/layouts/components/thread-view";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { useThreadSelection } from "@/features/providers/thread-selection";
|
||||
import { useCurrentFolderName } from "@/hooks/use-current-folder-name";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
const Mailbox = () => {
|
||||
const { selectedThreadIds } = useThreadSelection();
|
||||
const { selectedMailbox } = useMailboxContext();
|
||||
const folderName = useCurrentFolderName();
|
||||
const { defaultLayout, onLayoutChange } = useDefaultLayout({
|
||||
groupId: "threads",
|
||||
storage: localStorage,
|
||||
});
|
||||
|
||||
useDocumentTitle(folderName, selectedMailbox?.email);
|
||||
|
||||
return (
|
||||
<Group defaultLayout={defaultLayout} onLayoutChange={onLayoutChange} orientation="horizontal" className="threads__container">
|
||||
<Panel id="panel-thread-list" className="thread-list-panel" defaultSize="30%" minSize="250px" maxSize="50%">
|
||||
|
||||
Reference in New Issue
Block a user