diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 5bee1db7d..996bd107d 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -147,7 +147,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build | Next step | File | | --------- | ---- | | TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` | -| Push notifications — Web Push, Telegram, ntfy, APNs | `docs/self-hosting/notifications.md` | +| Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs | `docs/self-hosting/notifications.md` | | Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` | | Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` | | Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` | diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 7abf16df0..19e6878f4 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -3,17 +3,24 @@ HealthLog delivers reminders (medication doses, mood check-ins, personal records) and a few operational alerts through a small cascade of push channels. The single message to take away: **you do not need an Apple -Developer account or APNs to get push.** Three of the four channels are -free, work in the browser or in a messaging app you already have, and -cover every platform a self-hoster runs. APNs is only for the optional -native iOS app. +Developer account or APNs to get push.** Five of the six channels are +free, work in the browser or in an app you already have, and cover every +platform a self-hoster runs. APNs is only for the native iOS app, and +even that you can build yourself (see the last section). When an event fires, the dispatcher fans it out to every channel a user has enabled, in a fixed order — APNs first, then Telegram, then ntfy, -then Web Push as the universal fallback -(`src/lib/notifications/dispatcher.ts`). Each channel is best-effort and -independent: a user can enable one, two, or all four, and a failure on -one never blocks the others. +then the generic webhook, then email, then Web Push as the universal +fallback (`src/lib/notifications/dispatcher.ts`). Each channel is +best-effort and independent: a user can enable one or several, and a +failure on one never blocks the others. + +An APNs-less instance degrades gracefully. Nothing about push depends on +APNs being configured. Urgent events still surface everywhere it matters: +ntfy escalates them to max priority (`5`), Web Push raises urgency and +keeps the notification on screen until the user acts, and the webhook +carries the urgent flag for your own routing. APNs only adds the +Apple-platform exclusives the web cannot offer. ## TL;DR — which channel needs what @@ -22,7 +29,9 @@ one never blocks the others. | **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) | | **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs | | **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser | -| **APNs** | An Apple Developer account + a `.p8` push key | Apple Developer Program (paid) | The native iOS app only (TestFlight / App Store) | +| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself | +| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox | +| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign | Web Push is the recommended default — it needs no third-party account, just a keypair you generate yourself once. @@ -73,9 +82,8 @@ That prints a **public key** and a **private key** (both Base64URL). ### 2. Store the keys via environment variables (alternative) -If you would rather keep -secrets out of the database, set them in `.env` / the compose -`environment:` block instead: +If you would rather keep secrets out of the database, set them in `.env` / +the compose `environment:` block instead: ```env VAPID_PUBLIC_KEY="BElx...(public key)" @@ -96,7 +104,28 @@ button, and a Test button to fire a one-off push once subscribed. The browser prompts for notification permission on first subscribe; once granted, that browser receives every enabled event. -### iPhone caveat — Add to Home Screen first +## The PWA path — install to the Home Screen + +Installing HealthLog as a PWA and enabling Web Push is the recommended +default for phones. It needs nothing from Apple and gives you, in +v1.18.4: + +- **Reminders.** Medication doses, mood check-ins, and any other event + the user has enabled, delivered to the installed app even when it is + closed. +- **Taken → clears the reminder.** When you log a dose, the server sends + a silent `type:"clear"` push that closes the still-pending dose-due + reminder for that slot (matched on its stable tag) without showing a + new notification (`public/sw.js`). This is the PWA equivalent of ending + an iOS Live Activity. +- **An app badge for outstanding doses.** The icon on the Home Screen + carries a server-authoritative count of doses still due; logging a dose + decrements it, and a count of zero clears the badge. The count is + computed on the server and reflected by the service worker via the + Badging API (`navigator.setAppBadge`), feature-detected — engines + without it (most desktop Firefox, older Safari) silently no-op. + +### iPhone — Add to Home Screen first Safari on iPhone supports Web Push only from **iOS 16.4 or newer**, and only for a site that has been **installed to the Home Screen as a PWA**. @@ -112,6 +141,14 @@ A regular Safari tab cannot subscribe. So on iPhone: Android and desktop browsers have no such step — Subscribe and grant the permission, and push works. +### The inherent limit + +The installed PWA gets reminders, the taken-clears behaviour, and the app +badge — but it **cannot** show a native iOS lock-screen Live Activity +widget. That single feature rides Apple Push Notification service and is +only available through a native iOS build with APNs. Reminders themselves +arrive on both paths; only the Live Activity is APNs-exclusive. + ## Telegram Telegram delivers reminders as bot messages, with inline action buttons @@ -146,38 +183,112 @@ HealthLog publishes to that topic. If your ntfy server requires auth, an access token can be attached; the server URL is validated as a public host and dialled through the egress -guard (`src/lib/notifications/senders/ntfy.ts`). +guard (`src/lib/notifications/senders/ntfy.ts`). Urgent events are sent at +ntfy's top priority (`5` / max) so they bypass batching and surface on the +lock screen. -## APNs — native iOS app only +## Webhook -Apple Push Notification service is used by **only** the native SwiftUI -iOS app (TestFlight / App Store build). PWA and browser users never touch -it. Setting it up requires a paid **Apple Developer Program** membership. +The generic webhook POSTs every enabled event as JSON to a URL you own — +wire it into your own automation (Home Assistant, n8n, a Discord/Slack +relay, whatever routes the payload). You supply a public URL and, +optionally, one custom header (e.g. an auth token). -You do **not** need APNs to give iPhone users push: the installed PWA -gets every reminder over Web Push (see the iPhone caveat above). APNs adds -one Apple-platform exclusive the PWA cannot offer — the lock-screen Live -Activity — plus the tighter native delivery path. Reminders themselves -arrive on both. +1. In HealthLog open `/settings/notifications`, fill the Webhook card's + **URL** and, if your endpoint expects one, a single **header name** + + **header value**. +2. Save. A Test button fires a one-off POST so you can confirm routing. -If you do run the native app and want server-driven push, set the APNs -manifest (`src/lib/notifications/senders/apns.ts`) — three identifiers -plus exactly one key source: +The URL is validated as a public host and dialled through +`safeFetch({ requirePublicHost: true })` — the SSRF guard blocks private +and loopback ranges because the target is user-supplied +(`src/lib/notifications/senders/webhook.ts`). The webhook carries the +event's urgency flag so you can route urgent events differently. + +## Email (SMTP) + +The email channel sends reminders to a mailbox over an SMTP transport you +operate or rent. It is configured **instance-wide by the operator** (one +transport serves every user); each user then enables the channel and sets +their destination address from `/settings/notifications`. + +Set the SMTP transport in `.env` / the compose `environment:` block: + +```env +SMTP_HOST="smtp.example.com" +SMTP_PORT="587" +SMTP_FROM="HealthLog " +SMTP_USER="" # optional — omit for an unauthenticated relay +SMTP_PASS="" # optional +SMTP_SECURE="false" # true = implicit TLS (port 465); false = STARTTLS +``` + +`SMTP_HOST` + `SMTP_PORT` + `SMTP_FROM` together enable the channel; +`SMTP_USER` / `SMTP_PASS` are optional (omit them for an unauthenticated +relay). `SMTP_SECURE=true` uses implicit TLS on port 465; leave it `false` +for STARTTLS on 587. As with every var in `docker-compose.yml`, these must +be on the compose `environment:` whitelist to reach the container. + +## APNs — native iOS, and why it needs your own Apple account + +Apple Push Notification service is used by **only** a native SwiftUI iOS +app. PWA, browser, Telegram, ntfy, webhook, and email users never touch +it. It is the one channel a self-hoster cannot simply switch on, and the +reason is worth stating plainly. + +### Why your server can't push to the published app + +APNs binds a push to a specific **Apple Developer team + app bundle ID + +signing key**. The HealthLog iOS app published on TestFlight / the App +Store is signed under **the maintainer's Apple team and bundle ID**. Your +self-hosted server has neither that team's `.p8` key nor the authority to +mint one, so it **cannot** send APNs pushes to the published app. There is +no way around this — it is how Apple's trust model works, by design. + +To get native iOS push **and** the lock-screen Live Activity widget on +**your own** infrastructure, you have to publish your own copy of the app +under your own Apple identity. Everything below is that path. + +### Step by step — your own native iOS build + +1. **Get an Apple Developer account** (~$99/yr). Required to sign an iOS + app for a real device and to create a push key. +2. **Build and sign your own copy of the iOS app.** The client is open in + a separate repository (`healthlog-iOS`). Open it in Xcode and set your + **own bundle identifier** and **your team** as the signing team, then + build to your device (or distribute via your own TestFlight). +3. **Create your own APNs `.p8` auth key** in the Apple Developer Portal, + under **Keys → Create a Key → Apple Push Notifications service (APNs)**. + Note the **Key ID**, and your **Team ID** (top-right of the portal). + Scope the key **Sandbox & Production** (see the gotcha below). +4. **Point your bundle ID at your server.** Build the iOS app against your + instance so the device registers its APNs token with your server. +5. **Set the APNs manifest on the server.** Add the env vars below to + `.env` / the compose `environment:` block. + +The APNs env vars (`src/lib/notifications/senders/apns.ts`): | Variable | Purpose | | --- | --- | | `APNS_KEY_ID` | The `.p8` key's Key ID | | `APNS_TEAM_ID` | Your Apple Developer Team ID | -| `APNS_BUNDLE_ID` | The app's bundle identifier | +| `APNS_BUNDLE_ID` | Your app's bundle identifier | | `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) | | `APNS_KEY` | OR the raw PEM body inline | | `APNS_KEY_FILE` | OR a path to the `.p8` file | +| `APNS_CRITICAL_ENTITLEMENT` | Optional — set `true` only if Apple grants your app the Critical Alerts entitlement | -APNs is **all-or-none**: either set the three IDs plus one key source, or -leave the whole block unset. A partial config is rejected so the channel -never silently half-works (`scripts/check-env.ts`). When more than one key -source is present, the precedence is `APNS_KEY_B64` > `APNS_KEY` > -`APNS_KEY_FILE`. +APNs is **all-or-none**: either set the three IDs plus exactly one key +source, or leave the whole block unset. A partial config is rejected so +the channel never silently half-works (`scripts/check-env.ts`). When more +than one key source is present, the precedence is `APNS_KEY_B64` > +`APNS_KEY` > `APNS_KEY_FILE`. + +`APNS_CRITICAL_ENTITLEMENT=true` is honoured only if Apple has actually +granted your app the Critical Alerts entitlement (a separate request to +Apple). Without that entitlement, leave it unset — the server keeps urgent +events at normal priority rather than minting a `critical` payload your +app cannot send (`src/lib/notifications/types.ts`). Two gotchas worth knowing before you blame a stale token: @@ -190,9 +301,9 @@ Two gotchas worth knowing before you blame a stale token: `BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox & Production**; a wrongly scoped key cannot be reconfigured — re-issue it. -> **`clientManaged` is iOS-app-only.** The native iOS app can opt to run -> its own local medication reminders and ask the server to stop sending -> the duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on +> **`clientManaged` is iOS-app-only.** A native iOS app can opt to run its +> own local medication reminders and ask the server to stop sending the +> duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on > `PATCH /api/auth/me/notification-prefs`). This is a native-app contract: > a web/PWA-only self-hoster never needs it and there is no toggle for it > in the web UI. If you only run the PWA, ignore it entirely — your @@ -203,9 +314,11 @@ Two gotchas worth knowing before you blame a stale token: | Your setup | Recommended channel | | --- | --- | | PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. | -| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs the native app. | -| You want the native iOS app's full experience | **APNs** (paid Apple Developer account) *plus* Web Push as a fallback for any non-iOS device. | +| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. | +| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. | | Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. | +| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. | +| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. | Mixing channels is fine and common — e.g. Web Push on the laptop and ntfy on the phone. Each user configures their own set from