From ee04e95d4f19d0a050a476fc72e3f41a96adee3f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:17:43 +0200 Subject: [PATCH] feat: add UnifiedPush as an alternative to FCM for push notifications --- App.tsx | 7 + README.md | 2 +- android/app/build.gradle | 6 + android/app/src/main/AndroidManifest.xml | 5 + .../bulwarkmobile/BulwarkFcmPackage.kt | 1 + .../bulwarkmobile/BulwarkMessagingService.kt | 38 +- .../bulwarkmobile/BulwarkUnifiedPushModule.kt | 110 +++ .../BulwarkUnifiedPushService.kt | 100 ++ docs/audit-2026-09.md | 869 ++++++++++++++++++ locales/rn/en.json | 12 +- .../settings/NotificationSettings.tsx | 108 ++- src/lib/__tests__/push-notifications.test.ts | 110 +++ src/lib/push-notifications.ts | 154 +++- src/lib/unified-push.ts | 216 +++++ 14 files changed, 1701 insertions(+), 37 deletions(-) create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushModule.kt create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt create mode 100644 docs/audit-2026-09.md create mode 100644 src/lib/unified-push.ts diff --git a/App.tsx b/App.tsx index 36f35445..23c2676b 100644 --- a/App.tsx +++ b/App.tsx @@ -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]); diff --git a/README.md b/README.md index a29724b4..0d196210 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/android/app/build.gradle b/android/app/build.gradle index 043fd4ee..11e2d618 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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"; diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1a6db5be..f5ca4234 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -59,6 +59,11 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt index 1ab5516a..77854735 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt @@ -9,6 +9,7 @@ class BulwarkFcmPackage : ReactPackage { override fun createNativeModules(reactContext: ReactApplicationContext): List = listOf( BulwarkFcmModule(reactContext), + BulwarkUnifiedPushModule(reactContext), BulwarkClientCertModule(reactContext), ) diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt index ba033420..5a9f876b 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt @@ -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) { - 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) { + 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 diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushModule.kt new file mode 100644 index 00000000..d629cc89 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushModule.kt @@ -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) {} +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt new file mode 100644 index 00000000..81f9f196 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt @@ -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? { + 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? = 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 + } + } +} diff --git a/docs/audit-2026-09.md b/docs/audit-2026-09.md new file mode 100644 index 00000000..22bde7fb --- /dev/null +++ b/docs/audit-2026-09.md @@ -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 + +- [ ] **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.** +- [ ] **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 + +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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 + +- [ ] **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`). +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **U7 · P3 · S: Back in Files selection mode switches to the Mail tab instead of + clearing the selection.** + - **Verified.** +- [ ] **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.** +- [ ] **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. + +- [ ] **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. +- [ ] **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). +- [ ] **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. +- [ ] **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.** +- [ ] **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. +- [ ] **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.** +- [ ] **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.** +- [ ] **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.** +- [ ] **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). +- [ ] **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 + +- [ ] **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.** +- [ ] **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 `