All checks were successful
Implement a stateless Go service that routes /api/v1/send_push and /api/v1/ack by platform prefix to configured backends, enabling mixed official and custom mobile clients on a single PushNotificationServer URL. Co-authored-by: Cursor <cursoragent@cursor.com>
269 lines
19 KiB
Markdown
269 lines
19 KiB
Markdown
# 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/<id>/…`; 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:<hex>`, `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 <udid> 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=<router/plugin/proxy url>`; 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)
|
||
|