feat: add UnifiedPush as an alternative to FCM for push notifications

This commit is contained in:
Linus Rath
2026-09-24 18:17:43 +02:00
parent 6c82130267
commit ee04e95d4f
14 changed files with 1701 additions and 37 deletions
+7
View File
@@ -24,6 +24,7 @@ import {
teardownPushNotificationsForAccount,
type NotificationTapPayload,
} from './src/lib/push-notifications';
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
@@ -464,10 +465,16 @@ export default function App() {
const unsubscribe = addTokenRefreshListener(() => {
void doSetup();
});
// UnifiedPush equivalent of an FCM token rotation: the distributor handed
// out a new endpoint, so re-register it with the relay.
const unsubscribeUp = addUnifiedPushEndpointListener(() => {
void doSetup();
});
return () => {
cancelled = true;
unsubscribe();
unsubscribeUp();
};
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
+1 -1
View File
@@ -21,7 +21,7 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
- Email list, threads, compose
- Calendar (basic)
- Contacts (basic)
- Push notifications via FCM relay
- Push notifications via the Bulwark relay - FCM by default, or [UnifiedPush](https://unifiedpush.org) (e.g. ntfy) for devices without Google Play services
- In-app sideload updates from GitHub Releases
## What's missing or rough
+6
View File
@@ -171,6 +171,12 @@ dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
// UnifiedPush - Google-free push via a user-chosen distributor app such
// as ntfy. Coexists with FCM; the user picks the transport in settings.
// NOTE: 3.3.2-3.3.4 are built with Kotlin 2.3 and break this project's
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
implementation("org.unifiedpush.android:connector:3.3.5")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
+5
View File
@@ -59,6 +59,11 @@
</intent-filter>
</service>
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
</intent-filter>
</service>
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
</application>
</manifest>
@@ -9,6 +9,7 @@ class BulwarkFcmPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(
BulwarkFcmModule(reactContext),
BulwarkUnifiedPushModule(reactContext),
BulwarkClientCertModule(reactContext),
)
@@ -31,7 +31,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
// receives JMAP push directly, so the fcm:message event below is
// sufficient for it to refresh state.
if (!isAppInForeground()) {
startHeadlessTask(data)
startHeadlessTask(applicationContext, data)
}
val params = Arguments.createMap().apply {
@@ -42,26 +42,28 @@ class BulwarkMessagingService : FirebaseMessagingService() {
BulwarkFcmModule.emit("fcm:message", params)
}
private fun isAppInForeground(): Boolean {
val info = ActivityManager.RunningAppProcessInfo()
ActivityManager.getMyMemoryState(info)
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
}
private fun startHeadlessTask(data: Map<String, String>) {
val intent = Intent(applicationContext, BulwarkPushTaskService::class.java)
val bundle = Bundle().apply {
for ((k, v) in data) putString(k, v)
}
intent.putExtras(bundle)
applicationContext.startService(intent)
HeadlessJsTaskService.acquireWakeLockNow(applicationContext)
}
companion object {
const val CHANNEL_ID = "bulwark_mail"
// Shared with BulwarkUnifiedPushService - a push arriving over either
// transport is dispatched the same way.
fun isAppInForeground(): Boolean {
val info = ActivityManager.RunningAppProcessInfo()
ActivityManager.getMyMemoryState(info)
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
}
fun startHeadlessTask(context: Context, data: Map<String, String>) {
val intent = Intent(context, BulwarkPushTaskService::class.java)
val bundle = Bundle().apply {
for ((k, v) in data) putString(k, v)
}
intent.putExtras(bundle)
context.startService(intent)
HeadlessJsTaskService.acquireWakeLockNow(context)
}
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -0,0 +1,110 @@
package com.anonymous.bulwarkmobile
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import org.unifiedpush.android.connector.UnifiedPush
// JS-facing surface for UnifiedPush distributor management. Events from the
// distributor (endpoint changes, incoming messages) arrive through
// BulwarkUnifiedPushService, which emits them on the shared device-event
// channel.
class BulwarkUnifiedPushModule(reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "BulwarkUnifiedPush"
@ReactMethod
fun getDistributors(promise: Promise) {
try {
val array = Arguments.createArray()
for (d in UnifiedPush.getDistributors(reactApplicationContext)) array.pushString(d)
promise.resolve(array)
} catch (err: Exception) {
promise.reject("up_distributors_failed", err)
}
}
@ReactMethod
fun getSavedDistributor(promise: Promise) {
try {
promise.resolve(UnifiedPush.getSavedDistributor(reactApplicationContext))
} catch (err: Exception) {
promise.reject("up_distributor_failed", err)
}
}
@ReactMethod
fun getAckDistributor(promise: Promise) {
try {
promise.resolve(UnifiedPush.getAckDistributor(reactApplicationContext))
} catch (err: Exception) {
promise.reject("up_distributor_failed", err)
}
}
@ReactMethod
fun saveDistributor(distributor: String, promise: Promise) {
try {
UnifiedPush.saveDistributor(reactApplicationContext, distributor)
promise.resolve(null)
} catch (err: Exception) {
promise.reject("up_save_distributor_failed", err)
}
}
// Asks the saved distributor for an endpoint. The endpoint itself arrives
// asynchronously via BulwarkUnifiedPushService.onNewEndpoint (event
// `up:newEndpoint`); failures via `up:registrationFailed`. `vapid` is the
// relay's VAPID public key - optional, some distributors use it to
// restrict who may push.
@ReactMethod
fun register(vapid: String?, promise: Promise) {
try {
UnifiedPush.register(
reactApplicationContext,
messageForDistributor = null,
vapid = vapid?.takeIf { it.isNotBlank() },
)
promise.resolve(null)
} catch (err: Exception) {
promise.reject("up_register_failed", err)
}
}
@ReactMethod
fun unregister(promise: Promise) {
try {
UnifiedPush.unregister(reactApplicationContext)
BulwarkUnifiedPushService.clearEndpoint(reactApplicationContext)
promise.resolve(null)
} catch (err: Exception) {
promise.reject("up_unregister_failed", err)
}
}
@ReactMethod
fun getEndpoint(promise: Promise) {
val stored = BulwarkUnifiedPushService.readEndpoint(reactApplicationContext)
if (stored == null) {
promise.resolve(null)
return
}
val (url, p256dh, auth) = stored
val map = Arguments.createMap().apply {
putString("url", url)
putString("p256dh", p256dh)
putString("auth", auth)
}
promise.resolve(map)
}
// NativeEventEmitter required no-ops.
@ReactMethod
fun addListener(eventName: String) {}
@ReactMethod
fun removeListeners(count: Int) {}
}
@@ -0,0 +1,100 @@
package com.anonymous.bulwarkmobile
import android.content.Context
import com.facebook.react.bridge.Arguments
import org.json.JSONArray
import org.json.JSONObject
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
// Receives UnifiedPush events from the user's distributor app (ntfy, ...).
// The relay sends the same content-blind ForwardPayload it sends over FCM -
// as encrypted Web Push when the connector provided RFC 8291 keys (the
// connector decrypts before this service sees it) - so an incoming message is
// dispatched exactly like an FCM data message: headless task when the app is
// backgrounded, `fcm:message` device event for the live JS instance.
class BulwarkUnifiedPushService : PushService() {
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
saveEndpoint(this, endpoint)
val params = Arguments.createMap().apply {
putString("url", endpoint.url)
putString("p256dh", endpoint.pubKeySet?.pubKey)
putString("auth", endpoint.pubKeySet?.auth)
}
BulwarkFcmModule.emit("up:newEndpoint", params)
}
override fun onMessage(message: PushMessage, instance: String) {
BulwarkMessagingService.ensureChannel(this)
val data = parsePayload(message.content) ?: return
if (!BulwarkMessagingService.isAppInForeground()) {
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
}
// Same event name and shape as the FCM path so the foreground JS
// handler (App.tsx addMessageListener) works transport-agnostically.
val params = Arguments.createMap().apply {
val dataMap = Arguments.createMap()
for ((k, v) in data) dataMap.putString(k, v)
putMap("data", dataMap)
}
BulwarkFcmModule.emit("fcm:message", params)
}
override fun onUnregistered(instance: String) {
clearEndpoint(this)
BulwarkFcmModule.emit("up:unregistered", Arguments.createMap())
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
val params = Arguments.createMap().apply { putString("reason", reason.name) }
BulwarkFcmModule.emit("up:registrationFailed", params)
}
companion object {
private const val PREFS = "bulwark_unifiedpush"
private const val KEY_URL = "endpointUrl"
private const val KEY_P256DH = "endpointP256dh"
private const val KEY_AUTH = "endpointAuth"
// The endpoint arrives asynchronously (possibly while no JS instance
// is alive), so persist it for BulwarkUnifiedPushModule.getEndpoint.
fun saveEndpoint(context: Context, endpoint: PushEndpoint) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY_URL, endpoint.url)
.putString(KEY_P256DH, endpoint.pubKeySet?.pubKey)
.putString(KEY_AUTH, endpoint.pubKeySet?.auth)
.apply()
}
fun readEndpoint(context: Context): Triple<String, String?, String?>? {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val url = prefs.getString(KEY_URL, null) ?: return null
return Triple(url, prefs.getString(KEY_P256DH, null), prefs.getString(KEY_AUTH, null))
}
fun clearEndpoint(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
// The relay's ForwardPayload JSON, flattened to the string map the
// FCM data payload uses (repos/relay/src/fcm.ts) so the headless task
// and foreground handler can stay transport-blind.
fun parsePayload(content: ByteArray): Map<String, String>? = try {
val json = JSONObject(String(content, Charsets.UTF_8))
buildMap {
json.optString("kind").takeIf { it.isNotEmpty() }?.let { put("kind", it) }
json.optString("accountLabel").takeIf { it.isNotEmpty() }?.let { put("accountLabel", it) }
json.optString("accountId").takeIf { it.isNotEmpty() }?.let { put("accountId", it) }
put("emailIds", (json.optJSONArray("emailIds") ?: JSONArray()).toString())
put("changed", (json.optJSONObject("changed") ?: JSONObject()).toString())
}
} catch (_: Exception) {
null
}
}
}
+869
View File
@@ -0,0 +1,869 @@
# Native app audit, 2026-09-22
Bugs, performance and webmail gaps in the React Native app, as a TODO list. Every item carries
severity, effort, a `file:line` in this repo (paths relative to `repos/react-native`), the webmail
file to match where there is one (paths relative to the webmail repo root), a repro or measurement,
and whether it was **verified** (reproduced on a device, on the wire or by measurement) or is
**suspected** (code reading only).
Severity: P0 crash / data loss / security, P1 broken feature, P2 degraded, P3 polish.
Effort: S (hours), M (a day or two), L (a week or more).
## Scope and method
- **Code:** local `main` at `6c82130` (0.1.62) plus the uncommitted UnifiedPush work in the tree
(push files only). GitHub `main` is 2 commits ahead (`c2584c2`, `5b8c435`, PR #53: tappable
links in event descriptions); nothing else differs. Webmail reference: `main` at `9ef7068b`.
- **Server:** a throwaway Stalwart **0.16.23** in local Docker (`stw-native-audit`, stopped, kept).
The 1blu test host (`stw-test19`) was unusable: its Docker daemon was stopped and disabled on
2026-09-19. Accounts: `usera`, `userb`, and a `team` group that `usera` belongs to.
- Seed: Inbox 653, Archive 300, Sent 162, Projects/Alpha 40, Projects/Beta 25, Receipts 30,
Junk 15; team Inbox 80.
- Message types: threads, 80 heavy HTML newsletters (half HTML-only), attachments, an iMIP
invite, an embedded message, an inline cid image, RTL mail, and a read-receipt request.
- Also 27 events (one weekly recurring), 150 contacts (two with photos), and 515 file nodes
(folders created after the 500th).
- **Device:** Android emulator `Bulwark_Pixel7_API35` (x86_64, WHPX) running a release APK built
from the tree with a temporary cleartext flag (reverted afterwards).
- Driven with `uiautomator` and `input`.
- Every request went through a logging proxy that recorded JMAP methods, arguments and
set-errors.
- UI timings come from `screenrecord` frames diffed with ffmpeg. They are emulator numbers:
compare them with each other, not with a phone.
- **Static review:** covered selectors, rendering, request patterns, bundle and build, and the
webmail diff since 2026-08-29 (142 non-merge commits). Every item below that says **verified**
was re-checked on the device, on the wire, or by direct measurement (APK and bundle inspection).
### Test suite status
- `npx tsc --noEmit`: clean on the tree and on a clean `git archive` of HEAD.
- `npx vitest run`: green. 77 files and 970 tests pass on the tree; HEAD passes too, apart from
the load-related timeout below.
- The note "red on main, netinfo not mocked" is stale. `b84d4d8` (2026-08-29) mocks netinfo
in `src/test-setup.ts`.
- The only failure seen: `src/i18n/__tests__/keys.test.ts:45` timed out once, taking 16.8 s
against the 5 s default while gradle was building. Run alone it takes 0.4 s. It walks `src/`
synchronously, so give it a longer timeout.
- Live suite (`src/api/__tests__/live-stalwart.test.ts`): 14/14 pass against 0.16.23. All three
Stalwart quirks still exist in 0.16.23 and are handled on the paths the suite covers:
- the third `Email/set` response on a send;
- the same `partId` in `textBody` and `htmlBody`;
- `urn:stalwart:jmap` appearing only in `accountCapabilities`.
- The zustand selector sweep found no crash-class selectors: 685 call sites, none returns a
fresh object or array.
- Open issue #50 (crash opening any mail) did not reproduce in about 20 opens, including mail
with attachments, an invitation, a 586 KB body and RTL mail.
## Summary: fix these ten first
1. [ ] **P0 · S:** read receipts carry attacker-controlled headers and are sent for messages the user
never opened ([S1](#s1), [B7](#b7)).
2. [ ] **P0 · S–M:** published release APKs are signed with the public React Native debug key
([S2](#s2)).
3. [ ] **P0 · S–M:** viewer actions on a shared/group message hit the user's own account and can
delete an unrelated message ([B3](#b3)).
4. [ ] **P0 · S:** opening, starring or tagging replaces the whole keyword map; stars and tags are
erased ([B4](#b4)).
5. [ ] **P1 · S:** the viewer switches to another message when new mail arrives, and its buttons act
on the message you are not looking at ([B5](#b5)).
6. [ ] **P1 · S–M:** messages disappear from the list after a background refresh until you pull to
refresh ([B6](#b6)).
7. [ ] **P1 · S:** scheduled send, the undo-send delay and the Scheduled folder are dead on every
Stalwart server (#57, [B8](#b8)).
8. [ ] **P1 · S–M:** sharing a file into the app always fails, and `SENDTO`/`+`-address `mailto:` links
open nothing ([B9](#b9), [B10](#b10), [B11](#b11)).
9. [ ] **P1 · M:** opening a message takes 1.7–1.9 s and the layout jumps three times ([PF1](#pf1)).
10. [ ] **P1 · M:** cold start takes 3.6–4.1 s to a settled inbox and is JS-bound ([PF2](#pf2)).
**Why these ten:**
- **1–2:** security holes that can be exploited today without any user mistake.
- **3–6:** the app silently changes, hides or acts on the wrong mail. When users call the app
"buggy", this is the class that loses their trust, and all four were reproduced in minutes.
- **7–8:** core flows that fail for every Stalwart user or every share/`mailto:` user.
- **9–10:** the latency users feel on every launch and every tap, the "slow" in "buggy and slow".
Everything else below is narrower (file trees past 500 nodes; tasks, which are off by default),
suspected, or cosmetic.
## Bugs
### Crashes
- [ ] <a id="b1"></a>**B1 · P1 · M: session restore can hang forever when a persisted cache fails
to load.**
- Where: `src/stores/auth-store.ts:23-31`, `src/stores/auth-store.ts:603-608`,
`src/stores/settings-store.ts:423`.
- Why:
- AsyncStorage's Android database is capped at 6 MB, because `android/gradle.properties`
has no `AsyncStorage_db_size_in_MB`. Rows over about 2 MB can't be read back.
- The offline cache defaults to 50 MB. The email snapshot has no cap and stores the current
folder twice.
- zustand only marks a store hydrated when the read succeeds, and `waitForHydration` has
no timeout.
- **Suspected.**
- [ ] <a id="b2"></a>**B2 · P3 · S: the composer's WebView renderer process dies when the composer
closes.**
- Evidence: logcat shows `aw_browser_terminator … Renderer process (11521) crash detected
(code -1)` at 18:42:55 and again (11663) at 18:45:42, both on composer close.
- The app survives, but the cause is unknown.
- **Verified** (log only).
### Data correctness
- [ ] <a id="b3"></a>**B3 · P0 · S–M: viewer actions use the open folder's account instead of the
message's.**
- Where: `src/screens/EmailThreadScreen.tsx:107-113` (owner falls back to the list folder),
`src/screens/EmailThreadScreen.tsx:355-358` (delete), `:388-414` (archive, move, spam),
`src/stores/email-store.ts:1194-1197`.
- Web: `stores/email-store.ts` (`resolveUnstampedEmailAccountId`, rows stamped with their account).
- Repro:
- From the Unified Inbox, open team message `keaaaacr` and tap Delete. There is no confirmation.
- `Email/set` goes to account `c` and moves usera's own, unrelated "Security audit #18" (same
id, since Stalwart ids are only unique per account) to Trash.
- The team message stays where it was.
- **Verified.**
- [ ] <a id="b4"></a>**B4 · P0 · S: mark-read, star, pin and tag replace the whole `keywords`
map.**
- Where: `src/stores/email-store.ts:963-983`, `src/api/email.ts:602-609`. Outbox replay does
the same.
- Web: `lib/jmap/client.ts` (`keywords/$seen` pointer patch, `batchMarkAsRead`). RN already has
`patchKeywordsForEmails` at `src/api/email.ts:612`.
- Repro 1 (message not in the loaded list):
- Open a team message that has `$flagged` and `$label:work` from the Unified Inbox.
- The app sends `update {keywords: {"$seen": true}}`, and the server keeps only `$seen`. The
star and the tag are gone, while the viewer still shows the star.
- Repro 2 (message in the list): Star sent the full map `{$notjunk, $seen, $flagged}`. That
reverts any keyword another client changed since the list loaded.
- **Verified.**
- [ ] <a id="b5"></a>**B5 · P1 · S: the viewer pager follows the live list, so new mail shifts the
page under the user.**
- Where: `src/screens/EmailThreadScreen.tsx:81-102`, `:639`.
- Repro:
- Open "Quarterly numbers" (row 2) and deliver 2 new messages.
- The screen switches by itself to "Drift probe one".
- Tapping Star then flags "Quarterly numbers", which is no longer displayed. Delete and
Archive use the same `activeEmailId`.
- Fix: snapshot the id list when the viewer opens.
- **Verified.**
- [ ] <a id="b6"></a>**B6 · P1 · S–M: messages vanish after a delta refresh.**
- Where: `src/stores/email-store.ts:1953-1990` and `:2058-2060`.
- Web: `stores/email-store.ts` (`applyEmailDeltaNow`, which runs `Email/changes` against the
list's own state).
- Why:
- `inMailbox` is a mutable filter. Stalwart therefore reports every updated id as removed and
re-added.
- RN drops those ids and skips re-fetching them because they "already exist".
- The account-wide `emailState` token is deleted whenever a query returns no ids.
- Repro:
- Open the empty "Projects" folder, go back to Inbox, and flag "Please confirm receipt" from
another client.
- The refresh sends only `Email/queryChanges`, and the row disappears.
- A pull-to-refresh (a full query) brings it back.
- Before the empty-folder visit the same test worked (`queryChanges` → `changes` → `get`).
- **Verified.**
- [ ] <a id="b7"></a>**B7 · P1 · S: with "Always send", read receipts go out for messages that were
never displayed, and a failed one leaves junk in Sent.**
- Where: `src/components/email/ReadReceiptBanner.tsx:92-101` (mounted in the neighbouring pages
the pager pre-renders, `src/screens/EmailThreadScreen.tsx:641` `windowSize={3}`),
`src/api/email.ts:1456-1486`.
- Repro:
- Open "Invitation: Design review". The app uploads, imports and submits an MDN for the next
message, "Please confirm receipt", which the user never saw.
- Stalwart rejected this submission (a `.example` address), but the imported "Read: Please
confirm receipt" stays in Sent. `$mdnsent` is not set and a failed auto-send clears its guard
(`autoRef.current = null`), so it can run again and import another copy.
- **Verified.**
- [ ] <a id="b8"></a>**B8 · P1 · S: scheduled send and the undo-send delay are off on Stalwart
(#57).**
- Where: `src/api/jmap-client.ts:973-999` reads `session.capabilities[submission]`. Stalwart
only puts `maxDelayedSend` and FUTURERELEASE in `accountCapabilities`. `:919-925` (attachment
size cap) has the same problem.
- Web: `lib/jmap/client.ts` (reads `accountCapabilities`).
- Repro: the account advertises `maxDelayedSend: 2592000` with FUTURERELEASE. Composer clock
→ "In 1 hour" → "Scheduling unavailable: This mail server does not support scheduled send".
- The Scheduled screen and the undo delay use the same check.
- **Verified.**
- [ ] <a id="b9"></a>**B9 · P1 · S: files shared into the app never upload.**
- Where: `src/api/blob.ts:99` passes the `content://` URI to `createUploadTask`, which
requires a file path. `src/lib/share-intent.ts:40` names the file after the URI's last
segment.
- Repro: `am start -a SEND -t image/png --eu android.intent.extra.STREAM content://media/…/1000000027`
→ alert "Failed to upload 1000000027 … IOException: Directory for
'/external/images/media/1000000027' doesn't exist".
- **Verified.**
- [ ] <a id="b10"></a>**B10 · P1 · S: `ACTION_SENDTO mailto:` opens the inbox, not the composer.**
- Where: `android/app/src/main/java/com/anonymous/bulwarkmobile/ShareIntentStore.kt:25` only
handles SEND and SEND_MULTIPLE. RN's `Linking` forwards only VIEW. The manifest registers
SENDTO anyway.
- Repro: `am start -a android.intent.action.SENDTO -d mailto:bob@partner.example` → no
composer. The same URI with VIEW works.
- **Verified.**
- [ ] <a id="b11"></a>**B11 · P2 · S: `mailto:` links with a `+` address do nothing.**
- Where: `src/lib/mailto.ts:21` turns `+` into a space, validation then fails, and the parser
returns null.
- Web: `lib/validation.ts`.
- Repro: `mailto:alice+news@partner.example` → no composer.
- **Verified.**
- [ ] <a id="b12"></a>**B12 · P1 · S: tasks can't be completed (#958).**
- Where: `src/stores/calendar-store.ts:710-719` sends `progressUpdated`. The checkbox at
`src/components/calendar/TasksSheet.tsx:442` swallows the error.
- Web: `6b30e91c`, `stores/task-store.ts`.
- Repro: tap the task checkbox → `notUpdated: invalidProperties ["progressUpdated"]`. The task
stays `needs-action` and the UI shows no error.
- Tasks are off by default (`src/stores/settings-store.ts:381`), which limits the reach.
- **Verified.**
- [ ] <a id="b13"></a>**B13 · P1 · M: the Files tab stops at 500 nodes (#1069).**
- Where: `src/api/files.ts:98-112` (`FileNode/get ids:null`, which Stalwart silently caps at
`maxObjectsInGet`). The same code runs at `:133-167`.
- Web: `lib/jmap/client.ts` (`fetchAllFileNodes`, `9893b0a8`).
- Repro: with 510 root files created before the folders, `file-0490`–`0499` are listed, while
`file-0500`–`0509` and the Documents and Photos folders (and their files) never appear.
- **Verified.**
- [ ] <a id="b14"></a>**B14 · P2 · S: bodies over 512 KB render cut off with no notice (#884).**
- Where: `src/api/email.ts:493-499` (`maxBodyValueBytes: 512000`, `isTruncated` never read).
- Web: `lib/jmap/client.ts` (`2cf7427e`, refetch of truncated bodies).
- Repro: for a 586 KB HTML report, the server returns `isTruncated: true` with 59 characters.
The viewer shows the heading, one line and "…"; the list preview shows the full text.
- **Verified.**
- [ ] <a id="b15"></a>**B15 · P1 · S: a redirect that drops the Authorization header signs the user
out on launch.**
- Where: `src/api/jmap-client.ts:737-763`. The retry keys on `response.redirected`, which RN's
fetch never sets. An empty session then throws `AuthenticationError`, and
`src/stores/auth-store.ts:677-683` evicts the account.
- Web: `40e38d16` (#892).
- Only affects servers behind a redirecting proxy; direct Stalwart is fine.
- **Suspected.**
- [ ] <a id="b16"></a>**B16 · P1 · S: "Save draft" discards the text when identities didn't load
(offline) or there is no Drafts folder.**
- Where: `src/screens/ComposeScreen.tsx:1208` returns early; `:1288-1290` closes anyway.
- On iOS, the `beforeRemove` listener at `:1340` doesn't stop a native swipe-down; that needs
`usePreventRemove`.
- **Suspected.**
- [ ] <a id="b17"></a>**B17 · P1 · S: calendar reminders get cancelled when the calendar view
moves.**
- Where: `src/lib/calendar-notifications.ts:116-134` rebuilds the reminder set from the
windowed `events` and cancels everything else.
- Viewing another month drops all pending reminders. Nothing reschedules them in a session
that never opens the Calendar tab.
- There is no `setNotificationHandler`, so reminders don't show while the app is in the
foreground.
- **Suspected.**
- [ ] <a id="b18"></a>**B18 · P1 · S: push can end up permanently off for users with ACL-shared
mailboxes.**
- Where: `src/lib/push-notifications.ts` (HEAD `:223-229` builds the `emailPush` map from
every account with mailboxes).
- Stalwart rejects the whole map with `forbidden` for accounts the user doesn't own and isn't
a group member of. The refresh path then destroys the working subscription and the recreate
fails the same way.
- Web: `lib/web-push.ts:86-94` has the same code.
- **Suspected.**
- [ ] <a id="b19"></a>**B19 · P2 · S: "Reschedule" drops Cc and Bcc.**
- Where: `src/api/email.ts:1393` rebuilds recipients from `scheduled.to`;
`src/screens/ScheduledScreen.tsx:140`.
- Web: `lib/jmap/client.ts` (reuses the envelope).
- Unreachable on Stalwart until B8 is fixed.
- **Suspected.**
- [ ] <a id="b20"></a>**B20 · P2 · S: "Load more" skips messages in Unread and Starred views.**
- Where: `src/stores/email-store.ts:776` uses `position: emails.length`, which counts rows that
were kept after they stopped matching the query.
- **Suspected.**
- [ ] <a id="b21"></a>**B21 · P2 · S: the event editor wipes what the user typed when a calendar push
or the alias lookup arrives.**
- Where: `src/components/calendar/EventModal.tsx:231-273` (the reset effect depends on
`calendars` and `currentUserEmails`).
- **Suspected.**
- [ ] <a id="b22"></a>**B22 · P2 · S: an invitation's Accept sends no RSVP when the event is outside
the loaded calendar window, yet says "Added to calendar".**
- Where: `src/components/email/CalendarInvitationBanner.tsx:177-188`.
- **Suspected.**
- [ ] <a id="b23"></a>**B23 · P2 · S: "Send failed" can be shown for mail that was sent, so a retry
sends it twice.**
- Where: `src/api/email.ts:1178-1181` throws on any `error` response. Stalwart appends one when
`onSuccessUpdateEmail` fails after a successful submission.
- **Suspected.**
- [ ] <a id="b24"></a>**B24 · P3 · S: some writes report success when the server refused them.**
- Where: `src/api/files.ts:199` and `:225` (rename, move, delete read `[0][1]` without checking
for an `error` response); `src/api/calendar.ts:449-459` ignores `notDestroyed`;
`src/api/push.ts:14,82,105`.
- **Suspected.**
### UI glitches
- [ ] <a id="u1"></a>**U1 · P2 · S: the cold-start sequence shows a blank screen and jumps twice.**
- Where: `App.tsx:613-620`; `AppTheme` has no `windowBackground`; `PushOnboardingPrompt`.
- Frames:
- The dark splash is followed by about 1 s of blank white.
- Rows appear without a header, with the search bar under the status bar.
- The header then pops in and pushes the list down.
- The notification card lands over the status bar and pushes the list down again.
- **Verified** (`screenrecord`).
- [ ] <a id="u2"></a>**U2 · P2 · S: opening a message shows a skeleton, then a 120 px strip of body,
then the full body.**
- Where: `src/components/EmailBodyView.tsx:588-591` (the height resets to 120 on every
message).
- The header data is already in the list row but isn't used. The quick-reply box jumps as the
WebView grows. See PF1.
- **Verified.**
- [ ] <a id="u3"></a>**U3 · P2 · S: sender image and table widths are overridden.**
- Where: `src/lib/email-html.ts:452-454`. `img { max-width: 100% !important }` beats inline
`max-width`.
- Web: `68341a0b`, `ab4bf91b` (#790).
- Repro: an image with `max-width:200px; max-height:134px; width:100%` renders full width and
squashed about 1.9×; a table with `max-width:300px` spans the pane.
- **Verified.**
- [ ] <a id="u4"></a>**U4 · P2 · S: RTL mail renders left-to-right.**
- Where: `src/lib/email-html.ts:627` and `:686` (no `dir="auto"`).
- Web: `e2990da5`, `48048f16`.
- Repro: an Arabic plain-text body is left-aligned and its sentence-final period sits on the
wrong side.
- **Verified.**
- [ ] <a id="u5"></a>**U5 · P3 · S: list previews keep the server's newlines.**
- Rows show a dangling "…" line and have uneven heights ("Q3 revenue is up 12 %.\n\n…").
- A subject that contains CR/LF also renders over several lines in the list and the viewer.
- Where: `src/screens/EmailListScreen.tsx` row.
- **Verified.**
- [ ] <a id="u6"></a>**U6 · P3 · S: the schedule sheet offers presets, then refuses them.**
- Where: `src/screens/ComposeScreen.tsx:1846-1851`. The sheet should hide the presets.
- **Verified.**
- [ ] <a id="u7"></a>**U7 · P3 · S: Back in Files selection mode switches to the Mail tab instead of
clearing the selection.**
- **Verified.**
- [ ] <a id="u8"></a>**U8 · P3 · M: icon buttons and switches have no accessibility label.**
- Unlabeled controls:
- mail list: hamburger, select-all, filter;
- compose FAB;
- composer: close, attach, schedule;
- every calendar header button;
- per-file menu, settings switches, task checkbox.
- Web: `4947c6c7`.
- Repro: `uiautomator dump` shows an empty `content-desc` on all of these.
- **Verified.**
- [ ] <a id="u9"></a>**U9 · P3 · S: Android notifications use the stock envelope as their small icon.**
- Where: `android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt:128` and
`:181` (`android.R.drawable.ic_dialog_email`).
- **Suspected.**
## Performance
All numbers come from the x86_64 emulator with a release build and a local server. Use them to
compare against each other, not as phone numbers.
- [ ] <a id="pf1"></a>**PF1 · P1 · M: opening a message takes 1.65–1.87 s from tap to a settled
screen.**
- Where: `src/screens/EmailThreadScreen.tsx:161-199` (a network-first detail fetch even for
cached ids, plus a thread fetch with full bodies), `:641` (the pager mounts 3 panes, each
with its own WebView), `src/components/EmailBodyView.tsx:588-591` (the body is sanitized
twice per mount).
- Web: `stores/email-store.ts` and `mail-app.tsx` on `perf/fastest-mail` (header painted from the
row, prefetch on press).
- Measured:
- Plain 1 KB message 1.87 s; newsletter 1.65 s; two-message thread 1.72 s.
- Plain open, step by step: skeleton; header at +0.41 s; body at +1.02 s (clipped); resize
and quick-reply jump at +1.69 s.
- One open from the Unified Inbox made 12 requests: the body twice, the thread twice,
`Identity/get`, and a forbidden `x:Account/get`.
- **Verified** (numbers and requests); causes suspected.
- [ ] <a id="pf2"></a>**PF2 · P1 · M: cold start takes 3.6 / 3.8 / 4.1 s from launch to a settled
inbox (3 runs).**
- Measured:
- `am start` reports 451–564 ms to the first frame (the splash).
- Cached rows at about +1.2 s.
- The first network request at +1.84 s (`GET /jmap/session`).
- Then 700 ms of JS work before the 14-request delta burst.
- Network is not the bottleneck: every request finishes within 0.7 s.
- Causes:
- All 27 locale catalogs are imported and built at startup (`src/i18n/index.ts:11-37`), 36 %
of the 9.13 MB Hermes bundle.
- `lucide-react-native` is imported through its barrel in 93 files (e.g. `App.tsx:8`), which
evaluates all 1,694 icons (1.08 MB, 12 %).
- The date-fns locale barrel (`src/lib/calendar-locale.ts:2-13`) adds 830 KB.
- 3,586 of 3,993 modules run before the first frame.
- The session fetch waits for four serial AsyncStorage hydrations
(`src/stores/auth-store.ts:603-608`).
- The headless push task imports the whole app (`index.ts:10`).
- **Verified** (device timings; bundle shares measured by rebuilding the bundle with hermesc and
removing module groups one at a time).
- [ ] <a id="pf3"></a>**PF3 · P2 · M: the mail list is heavier than the emulator floor.**
- Measured over already-loaded rows (14 flings): p50 21 ms, p90 32 ms, p95 38 ms, p99 57 ms.
The same flings in the system Settings list: 17 / 18 / 20 / 36 ms.
- Causes:
- Inline `onEndReached` and `onRefresh`, and no `strictMode`, so FlatList re-runs every
`renderItem` (`src/screens/EmailListScreen.tsx:1285-1288`).
- SwipeableRow is not memoized, creates 4 new interpolations per render, and keeps both swipe
bands mounted in every row (`src/components/SwipeableRow.tsx:252-297`).
- Row dates build two `Intl` formatters per row (`src/lib/date-format.ts:75-97`).
- The PanResponder drag runs on the JS thread (`src/components/SwipeableRow.tsx:170-172`).
- **Verified** (numbers); causes suspected.
- [ ] <a id="pf4"></a>**PF4 · P2 · S: hidden screens keep rendering.**
- Where: there is no `enableFreeze` or `freezeOnBlur` (`App.tsx:634`, `index.ts`).
- The tab navigator subscribes to the whole `mailboxes` array to compute one badge
(`App.tsx:127`), and `src/stores/email-store.ts:1855` sets a new array on every mailbox
fetch.
- So every refresh also re-renders the mail list under an open message.
- **Suspected.**
- [ ] <a id="pf5"></a>**PF5 · P2 · S–M: every store write re-serializes the whole persisted cache.**
- Where: `src/stores/email-store.ts:1723-1731` (the email cache, with the current folder
stored twice), `src/stores/contacts-store.ts:824-829`,
`src/stores/calendar-store.ts:769-775`. This includes `loading` flips; there is no debounce.
- Opening one unread message causes about 5 full writes.
- **Suspected.** The stringify cost was measured off-device: 1.25 ms for 425 rows in V8.
- [ ] <a id="pf6"></a>**PF6 · P2 · S: requests that can be cut, all seen on the wire.**
- Every drawer open sends 14 `Email/query` calls for tag counts.
- The Contacts tab re-downloads all cards (58 KB) on every open, although they load at
startup.
- Returning to the Unified Inbox reloads page 1 of every account
(`src/screens/UnifiedInboxScreen.tsx:133-137`).
- The echo of the app's own writes triggers one or two full refresh cycles (10 requests after
one send).
- Starts and message opens send an `x:Account/get` that non-admin users are always refused.
- The sort-polarity probe runs on every start and is only cached in memory
(`src/lib/keyword-sort-polarity.ts:61-81`).
- Resume sends 2 `Core/echo`s, then waits about 0.8 s before syncing.
- There is a 30 s `Core/echo` on top of the SSE pings (`App.tsx:581-596`).
- Backgrounding is fine: no requests during 40 s in the background.
- **Verified.**
- [ ] <a id="pf7"></a>**PF7 · P2 · S: every list page and every search is 3 sequential requests.**
- The chain is `Email/query` → `Email/get` → `Thread/get` (the last one fired after render).
First login adds two `Mailbox/get` and a 500 ms gap.
- Where: `src/stores/email-store.ts:2049-2060`, `src/screens/EmailListScreen.tsx:339-358`.
- Web: `lib/jmap/client.ts` on `perf/fastest-mail` (query, get and `Thread/get` via
`#ids /list/*/threadId` in one request). RN's `src/api/unified-inbox.ts:383-397` already
chains query and get.
- **Verified.**
- [ ] <a id="pf8"></a>**PF8 · P2 · M: each keystroke in the composer re-renders the 3,000-line
screen twice.**
- Where: `src/lib/editor-html.ts:160-168` posts the full `innerHTML` on every input and on every
selection change; `src/screens/ComposeScreen.tsx:2276-2277`.
- `htmlToPlainText` and a `JSON.stringify` snapshot also run on every render.
- **Suspected.**
- [ ] <a id="pf9"></a>**PF9 · P2 · M: the APK is 42.8 MB (arm64) with R8 off.**
- Where: `android/app/build.gradle:72` and `:126-129`.
- Contents: 25.85 MB of unminified dex, 24,621 classes; the ML Kit barcode scanner accounts for
5.83 MB. The UnifiedPush work in progress adds 3,009 classes (Tink, protobuf).
- **Verified** (APK inspection of the published 0.1.62).
- [ ] <a id="pf10"></a>**PF10 · P3 · S: slow paths that need large data to show.**
- Contacts search re-sorts with `localeCompare` on every keystroke
(`src/stores/contacts-store.ts:923-925`).
- Bulk mark-read and star send N requests against a concurrency limit of 4
(`src/screens/EmailListScreen.tsx:668-681`); `setKeywordForEmails` exists at
`src/stores/email-store.ts:1530`.
- Files upload progress is not throttled.
- The offline-cache index is rewritten once per message.
- **Suspected.**
## Missing vs webmail
Status: *missing* (no native equivalent), *partial*, *diverges* (exists but behaves differently or
wrongly). Items already listed under Bugs are only cross-referenced here.
### Auth and accounts
- [ ] **P2 · S · missing: adding a second OAuth account silently signs in the same user again.**
- Where: `src/lib/oauth-native.ts:127-141` (no `prompt=select_account`, no ephemeral browser
session).
- Web: `5771a213`.
- **Suspected.**
- [ ] **P3 · S · partial: a pasted `…/jmap/session` URL fails discovery with a 404.**
- Where: `src/lib/server-discovery.ts:59-63`.
- Web: `b26c1746` (#971).
- **Suspected.**
- Also see B15 (#892 redirect).
### Mail list, folders and search
- [ ] **P2 · L · diverges: "All folders" search only covers the open folder's account (#1082).**
- Where: `src/stores/email-store.ts:384-389`.
- Web: `lib/unified-mailbox.ts` (`searchAcrossAccounts`, `9ef7068b`).
- Repro: "zephyr" with All folders → `Email/query` on account `c` only → 3 hits. The 5 matches
in the team inbox are missing.
- **Verified.**
- [ ] **P2 · M · diverges: the tag view and tag badges only cover one account (#1038).**
- Where: `src/stores/email-store.ts:209` and `:387`, `src/components/SidebarDrawer.tsx:426-436`.
- Web: `stores/email-store.ts` (`fetchTagEmails`, `fedfc89d`).
- Repro: 2 own and 2 team messages tagged Red → the Red view shows 2.
- **Verified.**
- [ ] **P3 · S · missing: folders created on the phone are not subscribed.**
- Where: `src/api/email.ts:211-221` sends no `isSubscribed: true`, so IMAP clients hide them.
- Web: `2b186910` (#951).
- **Suspected.**
- [ ] **P3 · S · diverges: archiving to a role-less "Archives" folder is offered but does nothing.**
- Where: the list matches by name (`src/screens/EmailListScreen.tsx:393-395`), but the store
needs the role or the exact name (`src/stores/email-store.ts:1202-1205`).
- Web: `2b32a011` (#578).
- **Suspected.**
- [ ] **P3 · S · missing: no unread badge on the app icon.**
- expo-notifications `setBadgeCountAsync` is already available.
- Web: `hooks/use-app-badge.ts` (`e7d37823`).
- **Suspected.**
- [ ] **P3 · S · missing: list rows show no attachment chips.**
- Web: `0e1c34cd`.
- **Suspected.**
- [ ] **P3 · S · missing: no "clear search when switching folders" setting.**
- Web: `12f01409`.
- **Suspected.**
- [ ] **P3 · S · diverges: the Unified section shows even when it can't be populated.**
- Where: `src/components/SidebarDrawer.tsx:639`.
- Web: `f3f0127c` (#843).
- **Suspected.**
- Not applicable: global search (Pro, desktop-only); category tabs (only exist when a plugin
registers them).
### Viewer
- [ ] **P2 · M · missing: no per-message HTML/plain-text toggle, and reopened drafts are forced into
the global format.**
- Where: `src/screens/ComposeScreen.tsx:531-536`. With plain text on, an HTML draft is
flattened on its first autosave.
- Web: `f6ad8a59` (#1022), `components/email/compose-format.ts`.
- **Suspected.**
- [ ] **P2 · S · missing: sender avatars ignore address-book photos (#54).**
- Web: `components/ui/avatar.tsx` (looks up the sender's contact photo by email; the photo wins
over favicons and initials).
- Repro: Alice Nguyen's contact photo shows in Contacts; her mail shows "AN" initials.
- **Verified.**
- [ ] **P2 · S · diverges: the viewer's Spam button is a plain move.**
- It sends no `$junk`/`$notjunk` flip and doesn't honour trash-and-read.
- Where: `src/screens/EmailThreadScreen.tsx:395-407`. The list and swipe paths are right
(`src/stores/email-store.ts:1058-1146`).
- Web: `150c25ca` (#695).
- **Suspected.**
- [ ] **P2 · S · diverges: viewer Archive silently does nothing for messages that are not in the
loaded list.**
- This covers the unified inbox, notification taps and deep links.
- Where: `src/stores/email-store.ts:1196-1197`.
- Web: `150c25ca`.
- **Suspected.**
- [ ] **P3 · S · partial: failed actions and sends give no feedback.**
- The store actions are called with `void`, and there is no "Email sent" toast.
- Web: `ec67ab99`, `b03a0c1d`, `lib/email-action-toast.ts`.
- **Verified** (no toast after send); failure path suspected.
- [ ] **P3 · S · missing: body-embedded `cid:` parts typed octet-stream appear as nameless chips.**
- Where: `src/lib/attachment-display.ts:91-104`.
- Web: `1c00812a`.
- **Suspected.**
- Also see B14 (truncation), U3 (image sizing), U4 (RTL), B5, B3.
### Composer
- [ ] **P2 · S · diverges: replies go out from the default identity unless "Reply from received
address" is on.**
- Forward also rewrites the From address, and replies to your own sent mail resolve from the
other party.
- Where: `src/screens/ComposeScreen.tsx:836-845`, `src/lib/reply-identity.ts:46-77,170`.
- Web: `4fc7b8b5`, `lib/reply-identity.ts`.
- **Suspected.**
- [ ] **P3 · S · missing: a new message in a shared folder doesn't default to that mailbox's
identity.**
- Where: `findComposeIdentityId` is ported but never called.
- Web: `129f545a`.
- **Suspected.**
- [ ] **P3 · S · missing: no exact-address vs same-domain choice for "reply from received".**
- Web: `565d5c10` (#1000).
- **Suspected.**
- [ ] **P3 · M · partial: no font-size picker or background colour; 8 text colours instead of 16.**
- Where: `src/components/RichTextEditor.tsx:8-26`.
- Web: `7e0a86a6`, `3957b358`.
- **Suspected.**
- [ ] **P3 · S · diverges: quick replies carry no signature.**
- Where: `src/components/email/QuickReplyBox.tsx:79-90`.
- **Suspected.**
- [ ] **P3 · S · partial: the undo-send snackbar exists only on the list screen.**
- A reply sent from the viewer can't be undone.
- Where: `src/screens/EmailListScreen.tsx:1563`.
- **Suspected.**
- Also see B8 (#57), B16.
### Calendar
- [ ] **P2 · S · missing: CalDAV tasks with `COMPLETED`, `NEEDS_ACTION` or `canceled` count as
pending and toggle the wrong way.**
- Web: `5f1d2795` (`normalizeCalendarTask`).
- **Suspected.**
- [ ] **P3 · L · missing: no free-scrolling month, week or day views (#759) and no setting for them.**
- Where: `src/screens/CalendarScreen.tsx:89,169-171,360-374`.
- Web: `15a5497a`, `ae403b06`, `hooks/use-scroll-window.ts`.
- **Suspected.**
- [ ] **P3 · M · missing: the agenda is a fixed 30-day page instead of infinite scroll.**
- Where: `src/components/calendar/AgendaView.tsx:46`.
- Web: `60e05c00`.
- **Suspected.**
- [ ] **P3 · L · diverges: recurring events are always expanded client-side.**
- Where: `src/api/calendar.ts:267-304`, `src/lib/recurrence-expansion.ts`.
- This matches the webmail's pre-0.16.20 fallback, but there is no server-side expansion and no
per-occurrence edit through synthetic ids.
- Web: `lib/recurrence-instances.ts`.
- **Suspected.**
- [ ] **P3 · S · partial: participants aren't deduplicated or matched to contact names, and the
organizer's status dot is wrong.**
- Where: `src/lib/calendar-participants.ts:130-167`.
- Web: `8cbc5bc5`, `6f99c017`.
- **Suspected.**
- [ ] **P3 · S · diverges: a task's due time shows the raw Europe/Berlin wall time (17:00), while
events are converted to the device zone.**
- **Suspected.**
- Links in event descriptions are fixed on GitHub `main` (`c2584c2`) but not in this checkout.
- Also see B12, B17, B21, B22.
### Contacts
- [ ] **P2 · S · partial: the vCard FN (`name.full`) is missing or stale on form, "add sender" and
import writes (#430).**
- Where: `src/screens/ContactFormScreen.tsx:288-293`, `src/lib/vcard.ts:575-603`.
- Web: `a210b22e`.
- **Suspected.**
- [ ] **P3 · S · missing: no sort by last name (#963).**
- Where: `src/stores/contacts-store.ts:923-925`.
- Web: `fb24ef5d`.
- **Suspected.**
- [ ] **P3 · S · missing: a new contact made while viewing address book X lands in the default
book.**
- Where: `src/screens/ContactsScreen.tsx:238,396`.
- Web: `494adb9b`.
- **Suspected.**
### Files
- [ ] **P2 · S · diverges: sharing with other users is never offered.**
- Where: `src/api/files.ts:30-35` and `src/components/calendar/CalendarShareSheet.tsx:86` gate
on `principals:owner`, which Stalwart advertises nowhere. The session only has `principals`
and `principals:availability`.
- Web: `components/files/files-app.tsx` (gates on `urn:ietf:params:jmap:principals`).
- Repro: the per-file menu shows Preview/Share (OS), Download, Move, Duplicate, Rename,
Delete, and no "Share with…".
- **Verified.**
- [ ] **P3 · M · missing: no in-app file preview.**
- Tapping a file downloads it and opens the OS "Open with" chooser.
- **Verified.**
- Also see B13 (500-node cap). WOPI editing (#425) is listed under open questions.
### Filters and vacation
- [ ] **P2 · S · missing: "Keep" is still written as `keep;`.**
- Saving filters on the phone therefore undoes the webmail fix for allow-list rules.
- Where: `src/lib/sieve/generator.ts:131-132`.
- Web: `62465e1e` (#1027).
- **Suspected.**
- [ ] **P3 · M · missing: no filters or vacation for shared/group accounts.**
- Where: `src/api/sieve.ts:20-23`, `src/api/vacation.ts:26-27`.
- **Suspected.**
### Settings, themes and i18n
- [ ] **P2 · M · partial: locale catalogs are stale.**
- Last synced 2026-08-29 (`990cd84`): 2,959 keys against the webmail's 3,065. The German
update changed 59 values.
- `email_viewer.forward_as_attachment` is still English in about 25 locales.
- The 567 RN-only keys are English in every locale (`src/i18n/index.ts:98-100`).
- Fix: run `scripts/sync-locales.mjs` and translate the RN overlay.
- **Suspected.**
- [ ] **P3 · S · partial: the Reading pane hard-codes English.**
- Labels such as "Message Spacing", "Read Receipts" and "Always send" don't go through `t()`
(`src/components/settings/ReadingSettings.tsx:305-327`).
- **Verified.**
- [ ] **P3 · S · diverges: plural forms probably fall back to English rules.**
- Where: `src/i18n/format.ts:18-47`. Hermes on Android has no `Intl.PluralRules`, so Russian
shows "2 писем".
- **Suspected.**
- [ ] **P3 · M · missing: only 4 of the webmail's 7 built-in themes, and no "Flat fields".**
- Where: `src/theme/builtin-themes.ts`.
- Web: `ab67d9b0`, `lib/theme-flat-fields.ts`.
- **Suspected.**
- [ ] **P3 · M · missing: no settings search.**
- Web: `lib/settings-search.ts`.
- **Suspected.**
- [ ] **P3 · L · diverges: native still uses Lucide icons; the webmail moved to Tabler (`d7548a4b`).**
- This is a product decision, not a bug.
- **Suspected.**
### Push
- [ ] **P1 · S · diverges: tapping a group-mailbox notification opens the message against the wrong
account.**
- The tap payload carries only the registry account, not the JMAP account.
- Where: `src/lib/push-background-task.ts:448-460`, `App.tsx:85-89`.
- Web: `b4f12b67` (#839).
- **Suspected.**
- [ ] **P2 · S · diverges: `emailPush` is never read back, so the subscription is re-patched on
every launch.**
- `using` also lacks the emailpush capability.
- Where: `src/api/push.ts:11` (`PushSubscription/get` with default properties).
- Web: `lib/jmap/client.ts` (explicit properties).
- **Suspected.**
- [ ] **P2 · S · diverges: revoking a device or disabling one account doesn't stick.**
- Setup re-runs for the active account on every launch (HEAD `App.tsx:447-453`).
- **Suspected.**
- UnifiedPush (#44, #48) exists only as uncommitted work in this tree. Also see B18.
### Security
- [ ] <a id="s1"></a>**S1 · P0 · S · missing: the GHSA-w38p fix (read-receipt header injection) was
never ported.**
- Where: `src/lib/mdn.ts:77-81` (`encodeHeaderWord` passes pure-ASCII values, CR/LF
included, through raw), `:111-113`, `:140-141`.
- Web: `lib/mdn.ts` (`stripHeaderControls`, `headerAtom`; `53a6670e`).
- Repro:
- Subject `=?UTF-8?B?…?=` decoding to `Contract update\r\nX-Injected: yes\r\nReply-To: attacker@evil.example`.
- Tap "Send receipt". The MDN stored in Sent and submitted has `X-Injected: yes` and
`Reply-To: attacker@evil.example` as real header lines.
- A blank line in the payload would give the attacker the body too.
- With B7 this becomes zero-click: the malicious message only needs to sit next to one the
user opens.
- **Verified.**
- [ ] <a id="s2"></a>**S2 · P0 · S–M: releases are signed with the public React Native debug key.**
- Where: `android/app/build.gradle:103-125`; `.github/workflows/release-android.yml:69-91` falls
back to it silently when `ANDROID_KEYSTORE_BASE64` is empty; `android/app/build.gradle:98`
(`versionCode 1`).
- Evidence: GitHub release 0.1.62 `bulwark-mobile-0.1.62-6c82130.apk` is signed by
`CN=Android Debug`, SHA-1 `5e8f1606…f625`. That is the stock template key, and its SHA-256
matches the committed `android/app/debug.keystore` (password `android`).
- Risk: anyone can sign an "update" that installs over the app and reads its SecureStore
credentials.
- Fix: a private key, a workflow that fails without it, v3 key rotation for existing
installs, and a `versionCode` derived from `VERSION`.
- **Verified.**
- [ ] **P2 · S · diverges: "Sync trusted senders to address book" trusts every contact in every
book.**
- Remote images and tracking pixels then load for any contact, including a spoofed From.
- The toggle switches itself on the first time the Content & Senders pane is opened on a
contacts-capable account (`src/components/settings/ContentSendersSettings.tsx:54-57`).
- Where: `src/components/EmailBodyView.tsx:533-544,562-566`.
- Web: `components/email/email-viewer.tsx` (only the Trusted Senders book counts).
- **Suspected.**
- [ ] **P2 · S · missing: replying or forwarding loads the remote images the viewer blocked.**
- The editor page has no CSP (`src/components/RichTextEditor.tsx:223-253`,
`src/lib/editor-html.ts:29-35`).
- **Suspected.**
- [ ] **P2 · S · diverges: the webmail-to-app sign-in hand-off puts the password in the
`bulwarkmobile://…#password=` fragment.**
- Where: `src/lib/oauth.ts:139-146`. The webmail side is `app/(main)/[locale]/login/page.tsx`.
- **Suspected.**
- [ ] **P3 · S · diverges: list and notification favicons are fetched from icons.duckduckgo.com on
the device.**
- This leaks sender domains and the device IP to a third party.
- Where: `src/lib/avatar-utils.ts:138-139`.
- Web: server-side `/api/favicon`.
- **Suspected.**
- Body WebView hardening is at parity: `about:blank` origin, no file access, DOM storage or
cookies, a `default-src 'none'` CSP, and the link-scheme gate.
## Stale parity checklist
Of the 418 items, 362 are ticked and 56 open; the counts match `PARITY_CHECKLIST.md`. The
spot-check covered 35 ticks plus the ones re-tested on the device.
### Ticked, but broken
- [ ] `docs/parity/03-email-viewer.md:107`, read receipts: sent unsanitized, and sent for unseen
neighbour panes (S1, B7). **Verified.**
- [ ] `docs/parity/02-mail-list-folders.md:112` and `docs/parity/03-email-viewer.md:15`, pager and
wrong-message fixes: the body is right, but actions still hit the wrong account and message
(B3, B5). **Verified.**
- [ ] `docs/parity/05-calendar.md:268`, task edit/toggle: completing fails (B12). **Verified.**
- [ ] `docs/parity/08-settings-push-i18n-ui.md:297`, deep links, mailto and share targets: SENDTO,
shared files and `+` addresses all fail (B9–B11). **Verified.**
- [ ] `docs/parity/07-filters-vacation-files.md:177` ("Verified at parity: `FileNode/get
ids:null`"): capped at 500 (B13). Its claim that `/query` drops folders has been outdated since
Stalwart 0.16.6. **Verified.**
- [ ] `docs/parity/02-mail-list-folders.md:244`, search all folders (#788): scope is one account
(#1082 above). **Verified.**
- [ ] `docs/parity/02-mail-list-folders.md:187`, `$junk`/`$notjunk` flip: the viewer's buttons
still do a plain move. **Suspected.**
- [ ] `docs/parity/01-auth-accounts.md:57` and `docs/parity/09-jmap-core-sync-security.md:99`,
redirect with a dropped Authorization header (B15). **Suspected.**
- [ ] `docs/parity/04-composer-send.md:12`, draft autosave: loses text offline, and iOS swipe-down
bypasses the dialog (B16). **Suspected.**
- [ ] `docs/parity/04-composer-send.md:224`, reschedule: drops Cc and Bcc (B19). **Suspected.**
- [ ] `docs/parity/05-calendar.md:261`, reminders: cancelled when the view moves (B17).
**Suspected.**
- [ ] `docs/parity/06-contacts.md:259`, trusted-senders book: trusts every contact (Security).
**Suspected.**
- [ ] `docs/parity/08-settings-push-i18n-ui.md:139` and `:154`, push types and the `emailPush`
filter: never read back, and breaks for ACL-shared accounts (B18). **Suspected.**
- [ ] `docs/parity/02-mail-list-folders.md:217`, date-locale: the tick claims a `dateLocale` setting
that doesn't exist. **Suspected.**
### Unticked, but done or obsolete
- **Done (tick them):**
| Item | Where it landed |
|---|---|
| `01:109` remove a non-active account | `6ae21d5`, `490355c` |
| `01:114` switcher error state | |
| `01:129` account cap | now 10, `src/lib/account-utils.ts:4` |
| `01:139` unified shared-account filter | |
| `02:163` permanent-delete confirm | `src/screens/EmailThreadScreen.tsx:361-386` |
| `02:168` "never" mark-as-read | |
| `04:256` mailto/share intents | Android; broken per B9–B11 |
| `06:200` add sender to contacts | |
| `06:281` auto-trust reply recipients | |
| `07:164` quota | shown in Account settings |
| `09:131` contact state changes for shared accounts | |
- **Obsolete (drop them):**
- `05:91` natural-language event input: the webmail has no parser.
- `06:294` print contact: not applicable on mobile.
- `07:45` Sieve editor gutter/Tab: not applicable on mobile.
- **Text that is now wrong:**
- `05:254`: push types are `['EmailDelivery']`.
- `05:222`, `06:215`, `07:152`: "no linking config" (untrue since `5802cae`).
- `08:15`, `08:235`: `useTypography` has 1 caller, not 3.
- `08:223`: "no RTL" (untrue since `990cd84`).
- `08:122`: cites `lib/error-reporting.ts`, which the webmail deleted.
- `07` "Verified at parity" Sieve generator: the webmail changed "Keep" (`62465e1e`).
## Open issues on bulwarkmail/native
| Issue | Status after this audit |
|---|---|
| #57 scheduled sending unsupported | Root cause found and reproduced (B8). |
| #54 contact photos not used as avatars | Reproduced (Viewer, "sender avatars"). |
| #50 crash opening any mail | Not reproduced on 0.1.62; likely fixed by `f228257`. Ask the reporter, then close. |
| #47 account security not accessible | Fixed: Security → Account Security works on 0.16.23. Close. |
| #46 email preview shows CSS | Not reproduced: HTML-only mail with `<style>` shows clean previews, and the viewer routes by type (`06742ef`). Ask for a sample, then close. |
| #44, #48 push without FCM / UnifiedPush | Uncommitted work in this tree; not in any release. |
| #3 TLS client auth | Partial: Android has a certificate picker (Settings → Security); iOS doesn't. |
| #58 encryption, #52 widgets, #51 IMAP/SMTP, #34 sync adapters, #1 settings sync | Feature requests; see open questions. |
No open webmail issue is about the native app. The six mobile-titled ones (#813, #838, #915,
#969, #999, #1020) concern the web and PWA.
## Open questions (product decisions)
- **Tasks** are off by default (`enableCalendarTasks: false`). Should they be on, now that the
webmail ships them?
- **Design language:** should native follow the webmail's "Flat fields" theme and Tabler icons,
or keep its own look?
- **Office documents:** WOPI editing (#425) needs the webmail as the WOPI host. Should native open
office files in Collabora through the webmail, or keep handing them to the OS?
- **Barcode scanner:** the bundled ML Kit scanner costs 5.8 MB and starts with every process,
including headless pushes. Google's code scanner needs Play services, which conflicts with the
Google-free UnifiedPush audience.
- **Package name:** `com.anonymous.bulwarkmobile` (Android) vs `org.bulwarkmail.mobile` (iOS).
This must be decided before any store listing, because changing it later means a new app.
- **Read receipts and the pager:** with neighbour pages pre-rendered, should "Always send" wait
for the page to be visible for some time, or apply only to the message that was actually
opened?
- **Settings sync (#1):** needs a server-side store, which neither client has today.
- **Scope of the feature requests:** encryption (#58), widgets (#52), IMAP/SMTP (#51) and native
sync adapters (#34).
## Reproducing
- **Test server:** the container `stw-native-audit` (Stalwart 0.16.23, `127.0.0.1:18095`) is
stopped with the seeded data kept.
- Start it with `docker start stw-native-audit`.
- Test accounts `usera@example.org` and `userb@example.org` use the same passwords as on
`stw-test19`; the recovery admin password is in the maintainer's notes, not here.
- To seed, raise `x:Jmap/set` `maxUploadCount` and `uploadQuota` first: the default of 1000
uploads or 50 MB per hour runs out.
- Email/import does not resolve a `#creationId` blobId from `Blob/upload` in the same request.
- **Emulator:** the server is `http://10.0.2.2:<port>`, which needs `android:usesCleartextTraffic`.
- Build from PowerShell in `C:\rn\android` with `NODE_PRESERVE_SYMLINKS=1`, JDK 17 and
`-PreactNativeArchitectures=x86_64`.
- For request traces, put a logging reverse proxy in front of the server. Pick a free local
port; 18096 and 18097 were already taken on this machine.
- **Incidental, webmail (suspected):**
- `lib/jmap/client.ts:3621` (and 3895, 4075, 4229, 8403, 8490) tests
`methodName.endsWith('/error')`. That never matches JMAP's `error` response, so a method-level
`EmailSubmission/set` error comes back as a successful send.
- `lib/web-push.ts:86-94` has the same shared-account `emailPush` problem as B18.
+10 -2
View File
@@ -529,16 +529,24 @@
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
"phase_account": "Account",
"phase_distributor": "UnifiedPush distributor",
"phase_jmap": "Mail server subscription",
"phase_permission": "Notification permission",
"phase_platform": "Platform",
"phase_relay": "Relay registration",
"phase_token": "Device token (Firebase)",
"phase_token": "Device token",
"phase_verify": "Verification",
"relay_desc_mobile": "The server that delivers your notifications. Defaults to the hosted Bulwark relay; change only if you self-host. Must use https.",
"relay_invalid": "Enter a valid https:// URL",
"setup_failed": "Setup failed",
"status_android_only": "Background notifications are available on Android only."
"status_android_only": "Background notifications are available on Android only.",
"transport_desc": "How notifications reach this device: Google’s Firebase Cloud Messaging, or a UnifiedPush distributor app (such as ntfy) for Google-free delivery.",
"transport_fcm": "Google (FCM)",
"transport_label": "Delivery method",
"transport_unifiedpush": "UnifiedPush",
"up_distributor_desc": "Several UnifiedPush distributor apps are installed - pick the one that should deliver notifications.",
"up_distributor_label": "Distributor",
"up_no_distributor": "No UnifiedPush distributor app is installed on this device. Install one (for example ntfy) and try again."
}
},
"offline": {
@@ -29,9 +29,18 @@ import {
setStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotificationsForAccount,
getStoredPushTransport,
setStoredPushTransport,
type PushDevice,
type PushSetupPhase,
type PushTransport,
} from '../../lib/push-notifications';
import {
getSavedUnifiedPushDistributor,
getUnifiedPushDistributors,
isUnifiedPushSupported,
saveUnifiedPushDistributor,
} from '../../lib/unified-push';
type PushStatus =
| { kind: 'idle' }
@@ -48,7 +57,8 @@ type DevicesState =
const PHASE_KEYS: Record<PushSetupPhase, { key: string; fallback: string }> = {
platform: { key: 'settings.notifications.push.phase_platform', fallback: 'Platform' },
permission: { key: 'settings.notifications.push.phase_permission', fallback: 'Notification permission' },
token: { key: 'settings.notifications.push.phase_token', fallback: 'Device token (Firebase)' },
token: { key: 'settings.notifications.push.phase_token', fallback: 'Device token' },
distributor: { key: 'settings.notifications.push.phase_distributor', fallback: 'UnifiedPush distributor' },
account: { key: 'settings.notifications.push.phase_account', fallback: 'Account' },
relay: { key: 'settings.notifications.push.phase_relay', fallback: 'Relay registration' },
jmap: { key: 'settings.notifications.push.phase_jmap', fallback: 'Mail server subscription' },
@@ -71,7 +81,11 @@ export function NotificationSettings() {
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const supported = isPushSupported();
const upSupported = isUnifiedPushSupported();
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
const [transport, setTransport] = useState<PushTransport>('fcm');
const [distributors, setDistributors] = useState<string[]>([]);
const [savedDistributor, setSavedDistributor] = useState<string | null>(null);
const [pushEnabled, setPushEnabled] = useState(false);
const [pushStatus, setPushStatus] = useState<PushStatus>({ kind: 'idle' });
const [confirmDisable, setConfirmDisable] = useState(false);
@@ -92,8 +106,16 @@ export function NotificationSettings() {
if (stored) setRelayUrl(stored);
setPushEnabled(enabled);
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
const storedTransport = await getStoredPushTransport();
const upDistributors = upSupported ? await getUnifiedPushDistributors() : [];
const upSaved = upSupported ? await getSavedUnifiedPushDistributor() : null;
if (cancelled) return;
setTransport(storedTransport ?? 'fcm');
setDistributors(upDistributors);
setSavedDistributor(upSaved);
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeAccountId]);
const trimmed = relayUrl.trim().replace(/\/+$/, '');
@@ -155,6 +177,27 @@ export function NotificationSettings() {
}
};
// Switching the transport while push is on re-registers immediately so the
// relay record flips to the new delivery path; while off it just records
// the preference for the next enable.
const handleSelectTransport = async (next: PushTransport) => {
if (next === transport || busy) return;
setTransport(next);
await setStoredPushTransport(next);
if (pushEnabled) void handleEnable(true);
};
const handleSelectDistributor = async (distributor: string) => {
if (distributor === savedDistributor || busy) return;
setSavedDistributor(distributor);
try {
await saveUnifiedPushDistributor(distributor);
} catch {
return;
}
if (pushEnabled && transport === 'unifiedpush') void handleEnable(true);
};
const performDisable = async () => {
setConfirmDisable(false);
setPushStatus({
@@ -239,6 +282,68 @@ export function NotificationSettings() {
</View>
</SettingItem>
{upSupported && (
<>
<SettingItem
label={t('settings.notifications.push.transport_label', 'Delivery method')}
description={t(
'settings.notifications.push.transport_desc',
'How notifications reach this device: Google’s Firebase Cloud Messaging, or a UnifiedPush distributor app (such as ntfy) for Google-free delivery.',
)}
>
<View style={styles.row}>
<Button
variant={transport === 'fcm' ? 'default' : 'outline'}
size="sm"
onPress={() => void handleSelectTransport('fcm')}
disabled={busy}
>
{t('settings.notifications.push.transport_fcm', 'Google (FCM)')}
</Button>
<Button
variant={transport === 'unifiedpush' ? 'default' : 'outline'}
size="sm"
onPress={() => void handleSelectTransport('unifiedpush')}
disabled={busy}
>
{t('settings.notifications.push.transport_unifiedpush', 'UnifiedPush')}
</Button>
</View>
</SettingItem>
{transport === 'unifiedpush' && distributors.length === 0 && (
<Text style={styles.errorText}>
{t(
'settings.notifications.push.up_no_distributor',
'No UnifiedPush distributor app is installed on this device. Install one (for example ntfy) and try again.',
)}
</Text>
)}
{transport === 'unifiedpush' && distributors.length > 1 && (
<SettingItem
label={t('settings.notifications.push.up_distributor_label', 'Distributor')}
description={t(
'settings.notifications.push.up_distributor_desc',
'Several UnifiedPush distributor apps are installed - pick the one that should deliver notifications.',
)}
>
<View style={styles.distributorList}>
{distributors.map((d) => (
<Button
key={d}
variant={d === savedDistributor ? 'default' : 'outline'}
size="sm"
onPress={() => void handleSelectDistributor(d)}
disabled={busy}
>
{d}
</Button>
))}
</View>
</SettingItem>
)}
</>
)}
<SettingItem
label={t('settings.notifications.push.relay_label', 'Push relay')}
description={t(
@@ -450,6 +555,7 @@ function makeStyles(c: ThemePalette) {
alignSelf: 'stretch',
},
urlInputInvalid: { borderColor: c.error },
distributorList: { flexDirection: 'column', alignItems: 'flex-end', gap: spacing.sm },
errorRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6, marginTop: spacing.sm },
errorText: { ...typography.caption, color: c.error },
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm },
@@ -17,6 +17,19 @@ vi.mock('react-native', () => {
getToken: vi.fn(async () => 'fcm-token-xyz'),
deleteToken: vi.fn(async () => undefined),
},
BulwarkUnifiedPush: {
getDistributors: vi.fn(async () => ['io.heckel.ntfy']),
getSavedDistributor: vi.fn(async () => 'io.heckel.ntfy'),
getAckDistributor: vi.fn(async () => 'io.heckel.ntfy'),
saveDistributor: vi.fn(async () => undefined),
register: vi.fn(async () => undefined),
unregister: vi.fn(async () => undefined),
getEndpoint: vi.fn(async () => ({
url: 'https://ntfy.sh/upAbCdEf?up=1',
p256dh: 'B'.repeat(87),
auth: 'a'.repeat(22),
})),
},
},
NativeEventEmitter,
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
@@ -273,6 +286,103 @@ describe('setupPushNotifications subscription shape', () => {
});
});
describe('setupPushNotifications over UnifiedPush', () => {
type UpNative = {
getDistributors: ReturnType<typeof vi.fn>;
getSavedDistributor: ReturnType<typeof vi.fn>;
register: ReturnType<typeof vi.fn>;
getEndpoint: ReturnType<typeof vi.fn>;
};
const upNative = () => (NativeModules as { BulwarkUnifiedPush: UpNative }).BulwarkUnifiedPush;
// Like installFetch, plus the two UnifiedPush relay endpoints; captures the
// registration body so tests can assert on it.
function installUpFetch(): { body: () => Record<string, unknown> | null } {
let captured: Record<string, unknown> | null = null;
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/api/push/vapid-public-key')) {
return { ok: true, status: 200, json: async () => ({ publicKey: 'VAPID-PUB' }) } as Response;
}
if (url.includes('/api/push/register/unifiedpush')) {
captured = JSON.parse(String(init?.body ?? 'null')) as Record<string, unknown>;
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
}
if (url.includes('/api/push/verify/')) {
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
return { body: () => captured };
}
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
await AsyncStorage.setItem('push:transport:v1', 'unifiedpush');
listMock.mockResolvedValue([]);
});
it('registers the distributor endpoint with the relay instead of an FCM token', async () => {
const relayReg = installUpFetch();
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
expect(result.verified).toBe(true);
const native = (NativeModules as { BulwarkFcm: { getToken: ReturnType<typeof vi.fn> } }).BulwarkFcm;
expect(native.getToken).not.toHaveBeenCalled();
// The relay's VAPID key is handed to the distributor at registration.
expect(upNative().register).toHaveBeenCalledWith('VAPID-PUB');
expect(relayReg.body()).toEqual({
subscriptionId: OUR_DCID,
endpoint: 'https://ntfy.sh/upAbCdEf?up=1',
keys: { p256dh: 'B'.repeat(87), auth: 'a'.repeat(22) },
accountLabel: undefined,
});
// The JMAP subscription flow is transport-independent.
expect(createMock).toHaveBeenCalledTimes(1);
});
it('sends keys: null for a legacy distributor without Web Push keys', async () => {
upNative().getEndpoint.mockResolvedValue({
url: 'https://legacy.example/up123456',
p256dh: null,
auth: null,
});
const relayReg = installUpFetch();
await setupPushNotifications({ relayBaseUrl: RELAY });
expect(relayReg.body()).toMatchObject({
endpoint: 'https://legacy.example/up123456',
keys: null,
});
});
it('fails with the distributor phase when no distributor is installed', async () => {
upNative().getDistributors.mockResolvedValue([]);
installUpFetch();
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
expect(err).toBeInstanceOf(PushSetupError);
expect(err.phase).toBe('distributor');
expect(createMock).not.toHaveBeenCalled();
});
it('requires an explicit choice when several distributors are installed', async () => {
upNative().getDistributors.mockResolvedValue(['io.heckel.ntfy', 'org.unifiedpush.distributor.sunup']);
upNative().getSavedDistributor.mockResolvedValue(null);
installUpFetch();
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
expect(err.phase).toBe('distributor');
expect(err.message).toContain('choose one');
});
});
describe('teardownPushNotificationsForAccount', () => {
beforeEach(async () => {
vi.clearAllMocks();
+139 -15
View File
@@ -11,11 +11,22 @@ import { getMailboxes, getSharedMailboxes } from '../api/email';
import { jmapClient } from '../api/jmap-client';
import type { EmailPushConfig, Mailbox } from '../api/types';
import { generateAccountId } from './account-utils';
import {
getUnifiedPushDistributors,
isUnifiedPushSupported,
registerUnifiedPush,
unregisterUnifiedPush,
UnifiedPushRegisterError,
type UnifiedPushEndpoint,
} from './unified-push';
// Persist identifiers across launches so we reuse the same JMAP subscription
// after app restarts. Each account gets its own deviceClientId so the hosted
// relay can distinguish per-account pushes via the URL slot it forwards.
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
// Which transport carries pushes to this device: FCM (default) or a
// UnifiedPush distributor. Device-wide, like the relay URL.
const PUSH_TRANSPORT_KEY = 'push:transport:v1';
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
// Local account id (username@host) → JMAP primary account id. The relay tags
// every forwarded push with the JMAP account id it came from, which is the
@@ -276,9 +287,28 @@ function getNative(): BulwarkFcmNative | null {
return (NativeModules as Record<string, unknown>).BulwarkFcm as BulwarkFcmNative | undefined ?? null;
}
/** True on platforms that have a push transport wired up (Android/FCM only). */
/** True on platforms that have a push transport wired up (Android only). */
export function isPushSupported(): boolean {
return getNative() !== null;
return getNative() !== null || isUnifiedPushSupported();
}
export type PushTransport = 'fcm' | 'unifiedpush';
export async function getStoredPushTransport(): Promise<PushTransport | null> {
const raw = await AsyncStorage.getItem(PUSH_TRANSPORT_KEY);
return raw === 'fcm' || raw === 'unifiedpush' ? raw : null;
}
export async function setStoredPushTransport(transport: PushTransport | null): Promise<void> {
if (!transport) {
await AsyncStorage.removeItem(PUSH_TRANSPORT_KEY);
} else {
await AsyncStorage.setItem(PUSH_TRANSPORT_KEY, transport);
}
}
export async function getEffectivePushTransport(): Promise<PushTransport> {
return (await getStoredPushTransport()) ?? 'fcm';
}
export interface PushSetupParams {
@@ -303,6 +333,7 @@ export type PushSetupPhase =
| 'platform'
| 'permission'
| 'token'
| 'distributor'
| 'account'
| 'relay'
| 'jmap'
@@ -390,16 +421,22 @@ export async function getFcmToken(): Promise<string | null> {
// Firebase rejects getToken() on devices without Google Play services (or
// with a broken Firebase configuration), and briefly right after a
// deleteToken(). Turn the raw native rejection into a phase-tagged error the
// settings pane can explain.
// settings pane can explain - and point de-Googled devices at UnifiedPush
// when a distributor is around to take over.
async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
let token: string | null = null;
try {
token = await native.getToken();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
const distributors = await getUnifiedPushDistributors().catch(() => []);
const hint =
distributors.length > 0
? ' This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.'
: '';
throw new PushSetupError(
'token',
`Firebase could not issue a device token (${detail}). Push needs Google Play services on this device.`,
`Firebase could not issue a device token (${detail}). Push over FCM needs Google Play services on this device.${hint}`,
);
}
if (!token) {
@@ -408,6 +445,39 @@ async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
return token;
}
/**
* The relay's VAPID public key, when it has one. Passed to the UnifiedPush
* distributor at registration so distributors that pin the application server
* accept the relay's pushes; registration proceeds without it (503 on relays
* without configured VAPID keys - UnifiedPush delivery still works there).
*/
async function fetchRelayVapidPublicKey(relayBaseUrl: string): Promise<string | null> {
try {
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key'));
if (!res.ok) return null;
const body = (await res.json()) as { publicKey?: unknown };
return typeof body.publicKey === 'string' && body.publicKey ? body.publicKey : null;
} catch {
return null;
}
}
/** Acquire a UnifiedPush endpoint, mapping failures onto setup phases. */
async function getUnifiedPushEndpointOrThrow(
relayBaseUrl: string,
): Promise<UnifiedPushEndpoint> {
const vapid = await fetchRelayVapidPublicKey(relayBaseUrl);
try {
return await registerUnifiedPush({ vapid });
} catch (err) {
if (err instanceof UnifiedPushRegisterError) {
const phase = err.reason === 'failed' ? 'token' : 'distributor';
throw new PushSetupError(phase, err.message);
}
throw new PushSetupError('token', err instanceof Error ? err.message : String(err));
}
}
function buildRelayUrl(base: string, suffix: string): string {
return base.replace(/\/+$/, '') + suffix;
}
@@ -454,6 +524,37 @@ async function registerWithRelay(params: {
}
}
async function registerWithRelayUnifiedPush(params: {
relayBaseUrl: string;
subscriptionId: string;
endpoint: UnifiedPushEndpoint;
accountLabel?: string;
}): Promise<void> {
const { endpoint } = params;
let res: Response;
try {
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/unifiedpush'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
endpoint: endpoint.url,
keys:
endpoint.p256dh && endpoint.auth
? { p256dh: endpoint.p256dh, auth: endpoint.auth }
: null,
accountLabel: params.accountLabel,
}),
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError('relay', `Could not reach the push relay at ${params.relayBaseUrl} (${detail}).`);
}
if (!res.ok) {
throw new PushSetupError('relay', `The push relay rejected the registration: ${await readRelayError(res)}`);
}
}
/** The relay's view of a subscription - see relayStatusFor. */
export type PushRelayStatus = 'active' | 'inactive' | 'unknown';
@@ -570,10 +671,14 @@ function logPhase(phase: string, detail?: string): void {
async function setupPushNotificationsInner(
params: PushSetupParams,
): Promise<PushSetupResult> {
const transport = await getEffectivePushTransport();
const native = getNative();
if (!native) {
if (transport === 'fcm' && !native) {
throw new PushSetupError('platform', 'Push notifications are only available on Android.');
}
if (transport === 'unifiedpush' && !isUnifiedPushSupported()) {
throw new PushSetupError('platform', 'UnifiedPush is only available on Android.');
}
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
if (!relayBaseUrl) throw new PushSetupError('relay', 'relayBaseUrl is required');
@@ -587,8 +692,14 @@ async function setupPushNotificationsInner(
throw new PushSetupError('permission', 'Notification permission was not granted.');
}
logPhase('token');
const fcmToken = await getFcmTokenOrThrow(native);
logPhase('token', transport);
let fcmToken: string | null = null;
let upEndpoint: UnifiedPushEndpoint | null = null;
if (transport === 'unifiedpush') {
upEndpoint = await getUnifiedPushEndpointOrThrow(relayBaseUrl);
} else {
fcmToken = await getFcmTokenOrThrow(native!);
}
// setupPushNotifications operates on the currently-loaded jmapClient. We
// need its username/serverUrl up-front so we can key per-account state.
@@ -606,15 +717,25 @@ async function setupPushNotificationsInner(
// Register this account's device-client-id with the relay. Multiple
// accounts on the same device end up as separate registrations sharing
// one fcmToken - the relay forwards each push individually and tags it
// with the JMAP account id so the headless task can route it.
// one FCM token / UnifiedPush endpoint - the relay forwards each push
// individually and tags it with the JMAP account id so the headless task
// can route it.
logPhase('relay', relayBaseUrl);
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
fcmToken,
accountLabel: params.accountLabel,
});
if (upEndpoint) {
await registerWithRelayUnifiedPush({
relayBaseUrl,
subscriptionId: deviceClientId,
endpoint: upEndpoint,
accountLabel: params.accountLabel,
});
} else {
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
fcmToken: fcmToken!,
accountLabel: params.accountLabel,
});
}
// Reuse the previous JMAP subscription when the server still has it, but
// push the expiry forward so it doesn't time out before the next app start.
@@ -853,6 +974,9 @@ export async function teardownPushNotifications(): Promise<void> {
if (native) {
await native.deleteToken().catch(() => undefined);
}
// Also release any UnifiedPush registration so the distributor stops
// holding a channel for us. No-op when never registered.
await unregisterUnifiedPush().catch(() => undefined);
}
export interface PushDevice {
+216
View File
@@ -0,0 +1,216 @@
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
// Thin JS surface over the BulwarkUnifiedPush native module. UnifiedPush
// (https://unifiedpush.org) delivers pushes through a user-installed
// distributor app (ntfy, NextPush, ...) instead of Google Play services -
// the transport for de-Googled devices. Incoming messages themselves are
// dispatched natively through the same headless task / `fcm:message` event
// as FCM, so only registration management lives here.
export interface UnifiedPushEndpoint {
url: string;
// RFC 8291 Web Push keys from the connector; null with legacy distributors,
// in which case the relay falls back to plain (unencrypted ping) delivery.
p256dh: string | null;
auth: string | null;
}
type BulwarkUnifiedPushNative = {
getDistributors(): Promise<string[]>;
getSavedDistributor(): Promise<string | null>;
getAckDistributor(): Promise<string | null>;
saveDistributor(distributor: string): Promise<void>;
register(vapid: string | null): Promise<void>;
unregister(): Promise<void>;
getEndpoint(): Promise<UnifiedPushEndpoint | null>;
};
function getNative(): BulwarkUnifiedPushNative | null {
if (Platform.OS !== 'android') return null;
return (
((NativeModules as Record<string, unknown>).BulwarkUnifiedPush as
| BulwarkUnifiedPushNative
| undefined) ?? null
);
}
/** True when the app build carries the UnifiedPush connector (Android only). */
export function isUnifiedPushSupported(): boolean {
return getNative() !== null;
}
/** Package names of the distributor apps installed on this device. */
export async function getUnifiedPushDistributors(): Promise<string[]> {
const native = getNative();
if (!native) return [];
try {
return await native.getDistributors();
} catch {
return [];
}
}
export async function getSavedUnifiedPushDistributor(): Promise<string | null> {
const native = getNative();
if (!native) return null;
try {
return await native.getSavedDistributor();
} catch {
return null;
}
}
export async function saveUnifiedPushDistributor(distributor: string): Promise<void> {
const native = getNative();
if (!native) throw new Error('UnifiedPush is not available on this platform.');
await native.saveDistributor(distributor);
}
export async function getUnifiedPushEndpoint(): Promise<UnifiedPushEndpoint | null> {
const native = getNative();
if (!native) return null;
try {
return await native.getEndpoint();
} catch {
return null;
}
}
export async function unregisterUnifiedPush(): Promise<void> {
const native = getNative();
if (!native) return;
await native.unregister().catch(() => undefined);
}
function emitter(): NativeEventEmitter {
return new NativeEventEmitter(NativeModules.BulwarkUnifiedPush);
}
/**
* Fires whenever the distributor hands out a (new) endpoint - including
* rotations while the app runs. Callers re-register the endpoint with the
* relay, mirroring the FCM token-refresh listener.
*/
export function addUnifiedPushEndpointListener(
listener: (endpoint: UnifiedPushEndpoint) => void,
): () => void {
if (!isUnifiedPushSupported()) return () => undefined;
const sub = emitter().addListener('up:newEndpoint', listener);
return () => sub.remove();
}
export function addUnifiedPushUnregisteredListener(listener: () => void): () => void {
if (!isUnifiedPushSupported()) return () => undefined;
const sub = emitter().addListener('up:unregistered', listener);
return () => sub.remove();
}
export class UnifiedPushRegisterError extends Error {
// 'no-distributor' - nothing installed; 'choose-distributor' - several
// installed and none selected yet; 'failed' - the distributor rejected or
// never answered the registration.
readonly reason: 'no-distributor' | 'choose-distributor' | 'failed';
constructor(reason: UnifiedPushRegisterError['reason'], message: string) {
super(message);
this.name = 'UnifiedPushRegisterError';
this.reason = reason;
}
}
/**
* Register with the saved distributor (auto-selecting it when exactly one is
* installed) and resolve with the endpoint. The endpoint arrives as an async
* event from the distributor; a previously stored endpoint short-circuits the
* wait - if the distributor then rotates it, the up:newEndpoint listener
* re-runs setup with the fresh one.
*/
export async function registerUnifiedPush(params: {
vapid?: string | null;
timeoutMs?: number;
}): Promise<UnifiedPushEndpoint> {
const native = getNative();
if (!native) {
throw new UnifiedPushRegisterError('failed', 'UnifiedPush is not available on this platform.');
}
const distributors = await getUnifiedPushDistributors();
if (distributors.length === 0) {
throw new UnifiedPushRegisterError(
'no-distributor',
'No UnifiedPush distributor app is installed. Install one (for example ntfy) and try again.',
);
}
let saved = await getSavedUnifiedPushDistributor();
if (!saved || !distributors.includes(saved)) {
if (distributors.length > 1) {
throw new UnifiedPushRegisterError(
'choose-distributor',
'Several UnifiedPush distributors are installed - choose one in the notification settings.',
);
}
saved = distributors[0];
await native.saveDistributor(saved);
}
const timeoutMs = params.timeoutMs ?? 30_000;
return new Promise<UnifiedPushEndpoint>((resolve, reject) => {
let settled = false;
const cleanups: Array<() => void> = [];
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
for (const c of cleanups) c();
fn();
};
const epSub = emitter().addListener('up:newEndpoint', (endpoint: UnifiedPushEndpoint) => {
finish(() => resolve(endpoint));
});
cleanups.push(() => epSub.remove());
const failSub = emitter().addListener(
'up:registrationFailed',
(payload: { reason?: string }) => {
finish(() =>
reject(
new UnifiedPushRegisterError(
'failed',
`The distributor rejected the registration (${payload?.reason ?? 'unknown'}).`,
),
),
);
},
);
cleanups.push(() => failSub.remove());
const timer = setTimeout(() => {
finish(() =>
reject(
new UnifiedPushRegisterError(
'failed',
`The distributor did not provide an endpoint within ${Math.round(timeoutMs / 1000)} s.`,
),
),
);
}, timeoutMs);
cleanups.push(() => clearTimeout(timer));
void native
.register(params.vapid ?? null)
.then(async () => {
// A re-registration usually keeps the existing endpoint, and the
// distributor may not re-announce it - use the stored one if present.
const stored = await native.getEndpoint().catch(() => null);
if (stored) finish(() => resolve(stored));
})
.catch((err: unknown) => {
finish(() =>
reject(
new UnifiedPushRegisterError(
'failed',
err instanceof Error ? err.message : String(err),
),
),
);
});
});
}