# Push Notifications — Design & Research Spec Status: **research complete, no code yet** (2026-07-17). This document consolidates the findings needed to implement push notifications for Bubbles across future sessions. Nothing here has been built; treat file/line anchors as "as of this writing". --- ## 1. Goal & decisions taken - Add push notifications so users are alerted to messages while the app is backgrounded (the live WebSocket in `Bubbles/Sync/` is **foreground-only** — iOS can't hold it suspended, so there is currently no background delivery path at all). - **Content mode: id-only** (`PushNotificationContents = id_loaded`). The push carries only ids; the device fetches the message and builds the notification locally. Chosen for privacy (message bodies never transit Apple or the proxy) and because it fits the local-first architecture (the fetched post is written to the DB, the single source of truth). - **Multi-server**, with the option for others to self-deploy — not a single hosted service everyone must use. --- ## 2. How Mattermost push works Pipeline (four hops): ``` Mattermost server ──▶ Push proxy (MPNS / self-hosted / plugin) ──▶ APNs (HTTP/2, .p8) ──▶ device ``` - The **server** decides a user should be notified (offline/away, mention, DM, …) and POSTs a payload to whatever URL is set in **System Console → Environment → Push Notification Server** (`EmailSettings.PushNotificationServer`). The server never talks to APNs directly. - The **proxy** holds the APNs credentials and relays to Apple. It routes using the `device_id` **platform prefix** (see §4). - Gating (confirmed in server source `server/channels/app/notification_push.go`): the whole path requires `EmailSettings.SendPushNotifications = true` **and** `EmailSettings.PushNotificationServer` set. Delivery uses `rawSendToPushProxy()` → `POST ‹server›/api/v1/send_push`; acks use `SendAckToPushProxy()` → `POST ‹server›/api/v1/ack` (a `model.PushNotificationAck`). ### id-only vs full | Mode (`PushNotificationContents`) | Body carries | Client must… | | --------------------------------- | ------------------------------------------- | -------------------------------------- | | `full` | message text, sender, channel | just display | | `id_loaded` (**chosen**) | ids only, `is_id_loaded: true`, **no text** | fetch the post, build the notification | ### id-only payload fields `ack_id`, `platform`, `server_id`, `device_id`, `post_id`, `root_id`, `category`, `channel_id`, `channel_name`, `team_id`, `sender_id`, `sender_name`, `type`, `version`, `badge`, `is_id_loaded`. - `server_id` is the server's **DiagnosticId**, **not** Bubbles' internal server UUID → a mapping is required (§7). - `ack_id` → optional `POST /api/v1/notifications/ack` (delivery metrics / email-fallback suppression). Not required for v1; **not exposed by the SDK**. Sources: [service docs](https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/service/), [iOS docs](https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/ios/), [5.18 id-only release](https://mattermost.com/blog/mattermost-5-18-id-only-option-for-push-notifications-one-click-plugin-updates-mark-posts-unread-and-more/), [notification_push.go](https://raw.githubusercontent.com/mattermost/mattermost/master/server/channels/app/notification_push.go). --- ## 3. The hard constraint: APNs key ⇄ bundle id APNs authorizes by **topic = bundle id (**`com.fmartingr.Bubbles`**)**. Only an APNs key (`.p8`) issued by **the Bubbles Apple Developer team** can push to that topic. Consequences: - Mattermost's hosted **HPNS** (`push.mattermost.com`) is bound to the *official* app's bundle id/keys and **cannot** deliver to Bubbles. A custom client always needs its own key in its own proxy. - Whatever sends Bubbles pushes must hold **your** `.p8`. This is true no matter how many proxies exist, so **do not ship the key inside a publicly distributed plugin/proxy** — it would leak to every operator. Ship keyless; configure the key per deployment. - "Full sovereignty" for a third party = they rebuild Bubbles under **their own** bundle id - own key (standard cost of custom Mattermost mobile builds). --- ## 4. `PushNotificationServer` is one server-wide setting → coexistence problem Confirmed: `EmailSettings.PushNotificationServer` is a **single, server-wide** value applied to **all** mobile clients/users; it **cannot** be scoped per app. Options in System Console: disabled / HPNS (US or DE) / TPNS (test) / **manually enter** a custom URL. The proxy chooses the APNs cert/key by matching the `device_id` **prefix** against `Type` entries in `ApplePushSettings` / `AndroidPushSettings`: - Official iOS/Android apps register as `apple_rn` / `android_rn` (also `apple`, `android`). - Stock proxy is a **terminal sender**: an unmatched `Type` is dropped; there is **no "forward upstream" option**. **Therefore:** if a server has official-app users and you repoint its single `PushNotificationServer` at a Bubbles-only proxy, **official push breaks** (their `apple_rn`/`android_rn` traffic has no valid Mattermost key on your proxy). Scope of the risk: - **Other servers:** unaffected — the setting is per-server. - **Same server, Bubbles-only:** nothing to break; point straight at a stock proxy with your key. - **Same server, mixed:** breaks unless you **relay** (below). Sources: [push server config](https://docs.mattermost.com/administration-guide/configure/push-notification-server-configuration-settings.html), [push-proxy sample config](https://github.com/mattermost/mattermost-push-proxy), [server.go](https://github.com/mattermost/mattermost-push-proxy/blob/master/server/server.go). --- ## 5. Recommended server-side design: a prefix router (no fork) Route by `device_id` prefix in front of **unmodified** backends: ``` Mattermost server └─ PushNotificationServer ─▶ [prefix router] ├─ apple_bubbles:* ─▶ your APNs sender (.p8, topic=com.fmartingr.Bubbles) └─ apple_rn:* / android_rn:* ─▶ HPNS (push.mattermost.com), relay /send_push + /ack ``` - **Bubbles sending needs no fork:** stock `mattermost-push-proxy` already supports multiple `ApplePushSettings` entries; add one whose `Type` matches your client's device prefix (e.g. `apple_bubbles`), topic = your bundle id, with your `.p8`. - **Relay is the only new logic.** It's a small, stateless shim (tens of lines of Go, or nginx + a body-inspecting route). It forwards the identical `send_push`/`ack` JSON to HPNS for official prefixes — the same call the server makes today, just one hop relayed. - One proxy/router is **stateless across Mattermost servers** (payload `server_id`/`device_id` disambiguate), so **one central router serves many servers — per-server proxies buy nothing** and multiply key handling. ("Per server" is only sensible in the plugin form, §6.3.) ### Form factors (same routing logic) 1. **Fork the proxy** — patch stock proxy to forward unmatched types. Works, but you carry a fork of a moving codebase. Least appealing. 2. **Shim + stock proxy (recommended for a central deploy)** — router is its own tiny service; Bubbles → stock proxy behind it; official → HPNS. No fork. 3. **Plugin (**`ServeHTTP`**)** — the plugin *is* the router, running in-server; admins upload it (System Console) instead of deploying anything. Best DX for self-hosters. Ship keyless. Confirmed viable: plugins serve at `/plugins//…`; set `PushNotificationServer` to that path. Uses the documented `/api/v1/send_push` + `/api/v1/ack` protocol, so it's version-stable. (Prefer this over the `NotificationWillBePushed` hook: that hook fires in `sendPushNotificationToAllSessions()` **before** `SetDeviceIdAndPlatform`, so it does **not** have the device id — unsuitable as the sender.) ### Deployment models - **Model A — central router you host** (holds your `.p8` + HPNS relay). Any cooperating server points its `PushNotificationServer` at it. Simplest for users; you bear cost/trust. Note: you can see notification *metadata* (channel/post ids) but not message bodies (id-only). - **Model B — self-host** via the plugin/shim. For operators who want no dependence on you; still needs a valid key (yours, or their own via a rebuilt app under their bundle id). Either way an **admin action is irreducible**: someone with System Console access must set the one global `PushNotificationServer` (and the router must relay if the server has official users). --- ## 6. Client-side work (iOS app) Current state (grep confirms **zero** notification code): single app target, no entitlements file, no App Group, no background modes, no extension target. `BubblesApp.swift` is a pure SwiftUI `App` with **no AppDelegate**. DB lives in `.applicationSupportDirectory` (`Bubbles/Database/AppDatabase.swift:167`), keyed Keychain token has no access group (`Bubbles/Network/TokenStore.swift`). ### 6.1 Already provided by the SDK (fork `fmartingr/MattermostSwift`, branch `feat/crt-unread-counters`) - `MattermostClient.attachMobileDevice(deviceID:)` → `PUT /users/sessions/device` (`MattermostClient+Users.swift:238`). - `detachMobileDevice(deviceID:)` → `DELETE /users/sessions/device` (`:247`). - `MattermostClient.login(…, deviceID:)` — attach at login (`MattermostClient.swift:69,85`). - `post(id:)` — single-post GET for the id-only fetch (`MattermostClient+Posts.swift:83`), returns `MattermostPost { id, createAt, userId, channelId, message, … }`. ### 6.2 To build | Area | Work | Anchor | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | Capability | `aps-environment` (Push Notifications), `UIBackgroundModes: [remote-notification]` | `project.yml` app target + generated `Support/Info.plist` | | App entry | `@UIApplicationDelegateAdaptor` AppDelegate: `didRegisterForRemoteNotificationsWithDeviceToken`, `didFailToRegister`; `UNUserNotificationCenterDelegate` for foreground + taps | `Bubbles/App/BubblesApp.swift` | | Permission + registration | request auth (after first login, not cold launch), `registerForRemoteNotifications()`, format token as `apple_bubbles:`, `attachMobileDevice` **per server** (iterate `AppState.servers`, `MattermostClientFactory.makeClient(for:)`) | new + `Bubbles/Services/AuthService.swift` | | Notification Service Extension | new `app-extension` target; reads `server_id`→server, reads token from **shared Keychain**, `post(id:)`, writes the post to the shared DB (mirror `LiveEventApplier.applyPosted` — counter bumps + `SyncService.post(from:).save(db)`), rewrites `bestAttemptContent` | new target; logic mirrors `Bubbles/Sync/LiveEventApplier.swift:102` | | App Group | `group.com.fmartingr.Bubbles`; move SQLite to `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)` + one-time migration of the existing file; GRDB multi-process/WAL coordination | `Bubbles/Database/AppDatabase.swift:167` | | Shared Keychain | add `kSecAttrAccessGroup` to all three queries so the NSE can read the token | `Bubbles/Network/TokenStore.swift` | | server_id mapping | persist each server's **DiagnosticId**; map push `server_id` → Bubbles server UUID. **SDK gap:** `MattermostClientConfig` does not expose `DiagnosticId` — extend the fork or raw-fetch `/config/client?format=old` | new `Server` column + migration (`Bubbles/Database/Records/Server.swift`, `AppDatabase.swift` migrator) | | Deep-link nav | tap → map `server_id`→serverID, `AppState.select(serverID:teamID:)`, push `Channel` onto the stack. `MainView`'s `NavigationStack` currently has **no bound** `path` — add one + an `AppState` pending-deep-link intake | `Bubbles/Features/Main/MainView.swift:23,92`, `Bubbles/App/AppState.swift` | | Lifecycle | attach on login; **detach on server removal** before the token is deleted (removal today cascades DB + deletes token — must also `detachMobileDevice` first) and on logout | `Bubbles/Features/Main/ServerEditModel.swift` / `ServerEditView.swift` | | Foreground de-dupe | when active, the live socket already delivers the post; suppress or in-app-banner the push, don't double-write | `Bubbles/Features/Main/MainView.swift` scenePhase handling | | Tests (required) | unit: token-format helper, `server_id`→server mapping, NSE post-write vs `AppDatabase.inMemory()`; XCUITest: permission-gated UI + deep-link intake. (NSE internals aren't XCUITest-drivable — keep them thin, test the pure functions.) | `BubblesTests/`, `BubblesUITests/` | --- ## 7. Dev environment & testing The iOS **Simulator cannot receive real APNs** (its device token isn't a real APNs token). Split testing in two: - **Client loop on the Simulator (no proxy/APNs):** `xcrun simctl push com.fmartingr.Bubbles bubbles-id-only.apns` with a Mattermost-shaped id-only aps payload. This triggers the real NSE → fetches the post from the local `dev/` server → writes to the shared DB, and exercises the tap/deep-link path. Covers ~90% of client work without any proxy. `attachMobileDevice` against the dev server also validates registration + payloads. - **Full chain (physical device required):** add to `dev/` `MM_EMAILSETTINGS_SENDPUSHNOTIFICATIONS=true`, `MM_EMAILSETTINGS_PUSHNOTIFICATIONCONTENTS=id_loaded`, `MM_EMAILSETTINGS_PUSHNOTIFICATIONSERVER=`; a real `.p8` for `com.fmartingr.Bubbles` in the sender; a physical iPhone reachable to the dev server (LAN IP, not `localhost`). --- ## 8. Open decisions 1. **Form factor:** shim + stock proxy (Model A, central) vs plugin (Model B, self-host). Both are "your router in front of your sender + HPNS". 2. **Distribution:** central router you operate vs plugin others install; and the documented BYO-bundle-id path for full sovereignty. 3. **Ownership:** willing to maintain a Go server component (shim/plugin, `sideshow/apns2`) alongside the Swift app + SDK fork. 4. **DiagnosticId gap:** extend the SDK fork's config model vs raw config fetch. --- ## 9. Source index - Push notification service — [https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/service/](https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/service/) - iOS push notifications — [https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/ios/](https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/ios/) - Set up push (custom app must self-host) — [https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/](https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/) - Host your own push proxy — [https://docs.mattermost.com/deployment-guide/mobile/host-your-own-push-proxy-service.html](https://docs.mattermost.com/deployment-guide/mobile/host-your-own-push-proxy-service.html) - Push server config (single setting, HPNS/TPNS/manual) — [https://docs.mattermost.com/administration-guide/configure/push-notification-server-configuration-settings.html](https://docs.mattermost.com/administration-guide/configure/push-notification-server-configuration-settings.html) - id-only feature — [https://mattermost.com/blog/mattermost-5-18-id-only-option-for-push-notifications-one-click-plugin-updates-mark-posts-unread-and-more/](https://mattermost.com/blog/mattermost-5-18-id-only-option-for-push-notifications-one-click-plugin-updates-mark-posts-unread-and-more/) , [https://mattermost.com/blog/id-only-push-notifications/](https://mattermost.com/blog/id-only-push-notifications/) - Push proxy repo + server.go — [https://github.com/mattermost/mattermost-push-proxy](https://github.com/mattermost/mattermost-push-proxy) , [https://github.com/mattermost/mattermost-push-proxy/blob/master/server/server.go](https://github.com/mattermost/mattermost-push-proxy/blob/master/server/server.go) - Server send path — [https://raw.githubusercontent.com/mattermost/mattermost/master/server/channels/app/notification_push.go](https://raw.githubusercontent.com/mattermost/mattermost/master/server/channels/app/notification_push.go) - Device attach endpoint (`PUT /api/v4/users/sessions/device`) — mattermost-api-reference `v4/source/users.yaml` - Plugin hooks (`ServeHTTP`, `NotificationWillBePushed`) — [https://pkg.go.dev/github.com/mattermost/mattermost/server/public/plugin](https://pkg.go.dev/github.com/mattermost/mattermost/server/public/plugin)