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>
19 KiB
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_idplatform prefix (see §4). - Gating (confirmed in server source
server/channels/app/notification_push.go): the whole path requiresEmailSettings.SendPushNotifications = trueandEmailSettings.PushNotificationServerset. Delivery usesrawSendToPushProxy()→POST ‹server›/api/v1/send_push; acks useSendAckToPushProxy()→POST ‹server›/api/v1/ack(amodel.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_idis the server's DiagnosticId, not Bubbles' internal server UUID → a mapping is required (§7).ack_id→ optionalPOST /api/v1/notifications/ack(delivery metrics / email-fallback suppression). Not required for v1; not exposed by the SDK.
Sources: service docs, iOS docs, 5.18 id-only release, 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(alsoapple,android). - Stock proxy is a terminal sender: an unmatched
Typeis 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, push-proxy sample config, 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-proxyalready supports multipleApplePushSettingsentries; add one whoseTypematches 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/ackJSON 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_iddisambiguate), 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)
- Fork the proxy — patch stock proxy to forward unmatched types. Works, but you carry a fork of a moving codebase. Least appealing.
- Shim + stock proxy (recommended for a central deploy) — router is its own tiny service; Bubbles → stock proxy behind it; official → HPNS. No fork.
- 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>/…; setPushNotificationServerto that path. Uses the documented/api/v1/send_push+/api/v1/ackprotocol, so it's version-stable. (Prefer this over theNotificationWillBePushedhook: that hook fires insendPushNotificationToAllSessions()beforeSetDeviceIdAndPlatform, 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 itsPushNotificationServerat 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), returnsMattermostPost { 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.apnswith a Mattermost-shaped id-only aps payload. This triggers the real NSE → fetches the post from the localdev/server → writes to the shared DB, and exercises the tap/deep-link path. Covers ~90% of client work without any proxy.attachMobileDeviceagainst 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.p8forcom.fmartingr.Bubblesin the sender; a physical iPhone reachable to the dev server (LAN IP, notlocalhost).
8. Open decisions
- Form factor: shim + stock proxy (Model A, central) vs plugin (Model B, self-host). Both are "your router in front of your sender + HPNS".
- Distribution: central router you operate vs plugin others install; and the documented BYO-bundle-id path for full sovereignty.
- Ownership: willing to maintain a Go server component (shim/plugin,
sideshow/apns2) alongside the Swift app + SDK fork. - 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/
- iOS push notifications — 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/
- Host your own push proxy — 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
- 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/id-only-push-notifications/
- Push proxy repo + server.go — https://github.com/mattermost/mattermost-push-proxy , 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
- Device attach endpoint (
PUT /api/v4/users/sessions/device) — mattermost-api-referencev4/source/users.yaml - Plugin hooks (
ServeHTTP,NotificationWillBePushed) — https://pkg.go.dev/github.com/mattermost/mattermost/server/public/plugin