mattermost-push-proxy-router/spec.md
Felipe M. 3c6fea8a46
All checks were successful
Release / release (push) Successful in 2m26s
CI / goreleaser-lint (push) Successful in 6s
CI / format (push) Successful in 28s
CI / test (push) Successful in 1m20s
CI / lint (push) Successful in 2m36s
CI / build (push) Successful in 34s
feat: add Mattermost push proxy prefix router
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>
2026-07-26 10:03:43 +02:00

19 KiB
Raw Permalink Blame History

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, 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 (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, push-proxy sample config, server.go.


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