Files
native/docs/audit-2026-09.md

64 KiB
Raw Permalink Blame History

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, B7). Fixed in cb6ddd6, 5f3f584.
  2. P0 · S–M: published release APKs are signed with the public React Native debug key (S2). Fixed in 6f0abb4, 746572c; the release key and secrets still have to be set up before the next release.
  3. P0 · S–M: viewer actions on a shared/group message hit the user's own account and can delete an unrelated message (B3). Fixed in 5c7301b.
  4. P0 · S: opening, starring or tagging replaces the whole keyword map; stars and tags are erased (B4). Fixed in 5041897.
  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). Fixed in 9c31de2.
  6. P1 · S–M: messages disappear from the list after a background refresh until you pull to refresh (B6). Fixed in 0a8686a.
  7. P1 · S: scheduled send, the undo-send delay and the Scheduled folder are dead on every Stalwart server (#57, B8). Fixed in e28e8b4.
  8. P1 · S–M: sharing a file into the app always fails, and SENDTO/+-address mailto: links open nothing (B9, B10, B11). Fixed in cd4ce9d, 06e8942, ee652d3 (not yet checked on a device).
  9. P1 · M: opening a message takes 1.7–1.9 s and the layout jumps three times (PF1). Fixed in 33e303e, ac8e3fb, 1348e7d, 01fc6fb, 6ebadf7, 54b169c (not re-timed yet).
  10. P1 · M: cold start takes 3.6–4.1 s to a settled inbox and is JS-bound (PF2). Fixed in 604eb39, 063432b, 5bcfe1f, 82ca359, b9db376 (not re-timed yet).

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.

Fix pass, 2026-09-24

One orchestrating session and one fix agent per area, each in its own git worktree; their commits were cherry-picked onto main (ee04e95..main, about 200 commits). npx tsc --noEmit and npx vitest run passed after every merge (2,028 tests at e5659ed). A ticked box means the fix landed with unit tests; the startup numbers in PF2 come from npx expo export, not device timings. A release build (x86_64, R8, -PallowDebugSignedRelease=true) was built and smoke-tested on the Bulwark_Pixel7_API35 emulator up to the sign-in screen: every Kotlin, XML, Gradle, Babel and Metro change compiles, cold start in light and dark mode is clean, the share and mailto: intents are accepted cold and warm, the QR scanner opens, and versionCode is 10062. No test account was available, so nothing behind sign-in has run on a device.

The device run also found two push bugs that predate this pass, now fixed: a downgraded FCM message or a UnifiedPush message crashed the app on Android 12+ with BackgroundServiceStartNotAllowedException (fef23e4, falls back to a JobScheduler job), and every push held a wake lock for 30 s because the new architecture never registered the headless-task "finished" module (5ddc2df).

Result: of the 93 bug, UI, performance and webmail-gap items, 83 are ticked, 7 are fixed in part and 3 are open (B2, Tabler icons, the password hand-off). 13 of the 14 stale parity entries are resolved; dateLocale is still missing.

Check on a device or server

  • Android intents (B9–B11): they compile and the app accepts them, but the composer only opens after sign-in. Signed in, check SEND with a content:// image, SENDTO mailto: with extras on a cold and a warm start, and VIEW mailto:alice+news@partner.example.
  • R8 (5e3cbf2): the signed-out screens run clean under R8. Push registration (FCM and UnifiedPush encryption), client certificates and the signed-in screens have not run under R8 yet. Releases now carry the R8 mapping for decoding stack traces (e5659ed).
  • Push fallback (fef23e4): tested with simulated FCM and UnifiedPush deliveries; confirm with a real downgraded FCM message and a real distributor (e.g. ntfy).
  • First build: 063432b adds babel.config.js and metro.config.js, so start Metro once with npx expo start --clear. The AsyncStorage limit from 24849ba only applies after a native rebuild.
  • Timings: record PF1, PF2, PF3, U1 and U2 again with screenrecord; none were re-measured.
  • Push: the forbidden fallback with a mailbox shared by ACL (d8046a8), revoke detection (a3d3af7), a group-mailbox notification tap (8618abf), and that a replacement subscription fits under maxPushSubscriptions, since the old one is kept until the new one is verified.
  • Echo skip (018accd): it assumes Mailbox.totalEmails matches the Email/query total on Stalwart; confirm on the wire.
  • Scheduled send on Stalwart: the 7-day hold clamp (4e7ae2f), the create-then-cancel reschedule (7e598c5) and scheduled mail held in shared accounts (72f48db).
  • Server-side recurrence (8454889, f397ee3, 96c5a3f): needs Stalwart 0.16.20 or later; check the synthetic-id probe, a single-occurrence edit and an RSVP to one occurrence.
  • Calendar time zone (a8e6f6e, 8af163a): open and save an event and a task with the calendar zone set away from the device zone.
  • Sieve round trip: save filters on the phone while the auto-reply is on; it should stay on, and the webmail should read the script back unchanged (d53bfec, 3f88868, 6c31c8d).
  • Sign-in: add a second OAuth account (ab84578), and restore a session behind a redirecting proxy (859770b).
  • Composer WebView: the editor CSP (235f4ea) and the 400 ms change throttle (fc00781).
  • iOS: the app-icon badge (25c0883) and the in-app PDF preview (409cdb1).
  • Translations (67935d2): 26 languages written without a native speaker; wording with no precedent in the webmail catalog needs a review.

Decisions for the maintainer

  • Release signing, before the next version bump: create the release key, the rotation lineage (apksigner rotate) and the five repository secrets (docs/android-release.md). The release workflow now fails without them, and a local release build needs -PallowDebugSignedRelease=true. versionCode is derived from VERSION (0.1.62 → 10062).
  • Trusted-sender sync: the webmail turns "Sync trusted senders to address book" on at login; the app now leaves it off until the user turns it on (d89e331). Follow the webmail, or keep it opt-in?
  • Sender favicons: still on by default, and the phone fetches them from icons.duckduckgo.com itself, having no proxy like the webmail's /api/favicon. Default off, a proxy, or keep?
  • Design language: "Flat fields" and the Tabler icons are not ported (open question below).
  • Short messages: the quick reply now sits at the bottom of the page so it never jumps (54b169c), which leaves empty space under a short message.
  • Android app badge: the icon badge is iOS-only (25c0883). On Android, expo-notifications clears a badge with cancelAll(), which would also dismiss mail notifications and reminders, and launchers already badge from the notifications on display.
  • Password hand-off: taking the password out of the bulwarkmobile://…#password= fragment needs a one-time code exchange on the webmail (/api/auth/mobile/handoff plus a redeem call, PKCE-style).
  • Composer font size: the webmail's font-size picker was left out; it needs a selection-rewriting command in the editor page that can only be tested on a device.
  • The other open questions at the end (tasks default, office documents, barcode scanner, package name, settings sync, feature requests) are unchanged.

Follow-ups worth doing

  • Offline cache: a cached message over about 2 MB can't be read back on Android; keep bodies in files.
  • Offline cache: eviction only counts the active account.
  • Session restore: a store that hydrates after the 5 s timeout merges over the restored state.
  • Calendar subscriptions and search history don't use createPersistStorage yet.
  • Bodies cached truncated before 4767805 are never fetched again.
  • Push: expires is patched on every launch, because Stalwart's 7-day cap equals the refresh threshold; check revoke detection and expiry for users who open the app less than weekly.
  • Push: the onboarding prompt comes back after push was turned off.
  • Webmail: lib/web-push.ts has the same shared-account emailPush bug as B18.
  • Mail list: the swipe drag still runs on the JS thread (needs gesture-handler or reanimated).
  • Unified Inbox: it still has the inline renderItem pattern that ebd8dac removed from the folder list, shows no attachment chips, and its patchRows duplicates the keyword-patch helper.
  • Mail: the webmail's opt-in "All unread" and "All starred" views are missing.
  • Composer: drafts can't be saved offline (B16 now says so instead of dropping the text).
  • Composer: findReceivingIdentity scans identities in list order; no "remove text colour".
  • Startup: the date-fns main barrel still adds about 300 modules; the 4.3 MB of catalogs stay in the bundle, only no longer evaluated at startup.
  • Startup: screens other than the mail list take their top inset from the native SafeAreaView and jump; the window colour follows the system theme, not the app's.
  • Intents: a mailto: without a recipient opens nothing; Bcc can't be passed through bulwarkmobile://compose.
  • Files: no refresh after a partly failed delete; the webmail's share-rights conversion for Stalwart before 0.16.6 (c3225b3f) is not ported.
  • File preview: no PDF renderer on Android, no audio, video, HTML or Markdown preview, and text is read as UTF-8 only.
  • Filters: calendar and contacts settings aren't scoped when managing a shared account; no UI for the forward limit (maxNumberRedirects) or the auto-reply length warning (webmail f3aec163); fileinto lingers in require.
  • Settings: the search doesn't index sidebar apps; theme names and descriptions aren't translated.
  • i18n: some toLocaleDateString() calls (settings panes, Files, contact detail) format in the device language, not the app's; formatDelta keeps its "d/h/min" units, like the webmail.
  • Push: React Native's own HeadlessJsTaskService can still start a task before the JS engine is ready when several high-priority pushes arrive during startup; that task then only ends at its 30 s timeout. The job fallback from fef23e4 avoids this.
  • Release: keep each release's …-mapping.txt (attached since e5659ed); decoding a user's stack trace needs the matching one.
  • Release workflow: the workflow_dispatch hint "leave empty to skip upload" doesn't match what the workflow does (older than this pass).
  • Upstream: GitHub main has c2584c2 and 5b8c435 (PR #53, links in event descriptions), which local main lacks.

Bugs

Crashes

  • B1 · P1 · M: session restore can hang forever when a persisted cache fails to load. Fixed in 29169c3, e37621b, 24849ba.
    • 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).
    • 2026-09-24: not reproduced; the composer is only reachable after sign-in and no test account was available for the device run.

Data correctness

  • B3 · P0 · S–M: viewer actions use the open folder's account instead of the message's. Fixed in 5c7301b, d2066da.
    • 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. Fixed in 5041897, ddff2f7.
    • 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. Fixed in 9c31de2, e0b7288.
    • 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. Fixed in 0a8686a.
    • 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. Fixed in 5f3f584, 1fb60a5.
    • 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). Fixed in e28e8b4, 4e7ae2f.
    • 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. Fixed in cd4ce9d, 3c7b96e.
    • 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. Fixed in 06e8942.
    • 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. Fixed in ee652d3.
    • 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). Fixed in dcdda5e, a7e4e84.
    • 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). Fixed in 4eb8c47, 115e162.
    • 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). Fixed in 4767805.
    • 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. Fixed in 859770b.
    • 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. Fixed in 92b42e4: the text is no longer dropped and the iOS swipe is blocked; saving a draft offline is still impossible.
    • 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. Fixed in 4b903ed.
    • 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. Fixed in d8046a8.
    • 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. Fixed in e67947a, 7e598c5.
    • 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. Fixed in 35e0792.
    • 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. Fixed in 15717f1.
    • 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". Fixed in c0582ab.
    • 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. Fixed in c51a848.
    • 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. Fixed in 00ad2f4 (files), a6ef0ab (calendar), ddfe85a (push).
    • 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. Fixed in b9db376, f2dd3a8.
    • 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. Fixed in 54b169c, ac8e3fb.
    • 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. Fixed in 7531118.
    • 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. Fixed in 910c503.
    • 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. Fixed in d45c799.
    • 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. Fixed in 802b199, 4e7ae2f.
    • 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. Fixed in 8d7fbfc, 4fdf39a.
    • Verified.
  • U8 · P3 · M: icon buttons and switches have no accessibility label. Fixed in 9c0c35c, 771558d, aab29da, d59c571, cb5c152, c1e5cd6 (mail list, compose button, composer, Files, contacts, settings) and 13cba64, b3a82f1, 84fef14, b178ee8 (viewer, calendar header, calendar sheets and task checkbox, tab bar, unified inbox, drawer).
    • 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. Fixed in 695aa23.
    • 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. Fixed in 33e303e, ac8e3fb, 1348e7d, 01fc6fb, 6ebadf7, 54b169c, 7cdebc4, b7a5953; not re-timed on a device yet.
    • 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). Fixed in 604eb39, 063432b, 5bcfe1f, 82ca359, b9db376, 9c2f819: modules run before the first frame 3,590 → 1,516, Hermes bundle 9.34 → 7.61 MB, a headless push start 202 modules (npx expo export). The four hydrations were already awaited together (85f23df). Not re-timed on a device yet.
    • 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. Partly: cb9a741, aa25075, ebd8dac fix the inline callbacks, the swipe bands and the per-row date formatters; the swipe drag still runs on the JS thread, and nothing was re-measured.
    • 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. Partly: 4a5d837 adds freezeOnBlur and a count-only badge selector; the list directly under an open message stays live on Fabric, and fetchMailboxes still sets a new array on every fetch.
    • 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. Fixed in 48825e9, e37621b.
    • 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. Fixed in d097ae9, 668e010, 1c7c2a3, 018accd, 3bf2c30, 1196ad5, 9fa931c.
    • 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/echos, 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. Fixed in d987930, 4f3940f, 9c2f819.
    • 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. Fixed in fc00781.
    • 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. Fixed in 5e3cbf2: on the x86_64 build the APK went from 44.7 to 38.0 MB, dex from 30.4 MB in 4 files to 11.3 MB in 2, and classes from 27,631 to 16,724. The rest is native libraries (ML Kit alone 5.9 MB) and the JS bundle, which R8 doesn't touch.
    • 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. Partly: contacts search (e6be892) and bulk read/star (cb2f03b) are fixed; upload progress and the offline-cache index writes are unchanged.
    • 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. Fixed in ab84578.
    • 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. Fixed in bd42206.
    • Where: src/lib/server-discovery.ts:59-63.
    • Web: b26c1746 (#971).
    • Suspected.
  • Also see B15 (#892 redirect).
  • P2 · L · diverges: "All folders" search only covers the open folder's account (#1082). Fixed in ee50418, edab181, d2066da.
    • 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). Fixed in 8d5bced, d097ae9.
    • 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. Fixed in 30aea63.
    • 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. Fixed in 9f75e70, ad1f053.
    • 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. Partly: 25c0883 badges the icon on iOS; Android is left to the launcher (see the fix-pass decisions).
    • expo-notifications setBadgeCountAsync is already available.
    • Web: hooks/use-app-badge.ts (e7d37823).
    • Suspected.
  • P3 · S · missing: list rows show no attachment chips. Fixed in b1fb2e9, edab181 (not yet on Unified Inbox rows).
    • Web: 0e1c34cd.
    • Suspected.
  • P3 · S · missing: no "clear search when switching folders" setting. Fixed in 4e64261.
    • Web: 12f01409.
    • Suspected.
  • P3 · S · diverges: the Unified section shows even when it can't be populated. Fixed in af6290f.
    • 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. Fixed in 472c4d8.
    • 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). Fixed in eca3871.
    • 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. Fixed in 5e42c67.
    • 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. Fixed in 33d2674.
    • 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. Fixed in 9e95273, cd911ad.
    • 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. Fixed in 1d1d6a5.
    • 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. Fixed in a0c2bd7.
    • 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. Fixed in 0ffdcac.
    • Where: findComposeIdentityId is ported but never called.
    • Web: 129f545a.
    • Suspected.
  • P3 · S · missing: no exact-address vs same-domain choice for "reply from received". Fixed in 2d907ff.
    • Web: 565d5c10 (#1000).
    • Suspected.
  • P3 · M · partial: no font-size picker or background colour; 8 text colours instead of 16. Partly: 548b647 adds the background colour and the 16 text colours; the font-size picker is not ported.
    • Where: src/components/RichTextEditor.tsx:8-26.
    • Web: 7e0a86a6, 3957b358.
    • Suspected.
  • P3 · S · diverges: quick replies carry no signature. Fixed in 3faac98.
    • Where: src/components/email/QuickReplyBox.tsx:79-90.
    • Suspected.
  • P3 · S · partial: the undo-send snackbar exists only on the list screen. Fixed in e3501f9: the bar is hosted once per stack screen, and quick replies record their hold.
    • 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. Fixed in 1b64c14.
    • Web: 5f1d2795 (normalizeCalendarTask).
    • Suspected.
  • P3 · L · missing: no free-scrolling month, week or day views (#759) and no setting for them. Fixed in caf61ec, de3d697.
    • 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. Fixed in 71522b9.
    • Where: src/components/calendar/AgendaView.tsx:46.
    • Web: 60e05c00.
    • Suspected.
  • P3 · L · diverges: recurring events are always expanded client-side. Fixed in 8454889, f397ee3, 96c5a3f.
    • 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. Fixed in 1d755fc.
    • 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. Fixed in 6a975b8, 1a651a9, a8e6f6e.
    • 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). Fixed in a734d0a.
    • 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). Fixed in 1827748.
    • 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. Fixed in c888e95 (groups: a3193eb).
    • Where: src/screens/ContactsScreen.tsx:238,396.
    • Web: 494adb9b.
    • Suspected.

Files

  • P2 · S · diverges: sharing with other users is never offered. Fixed in caea3d0.
    • 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. Fixed in 409cdb1 (images, text, and PDFs on iOS; Android PDFs, audio and video still go to another app).
    • 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;. Fixed in f63d739.
    • 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. Fixed in a86cd1d.
    • 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. Fixed in 55f1ec4, 008bff2, 67935d2.
    • 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. Fixed in 2511df1.
    • 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. Fixed in e6549d3.
    • 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". Partly: 7964e64 adds Roundcube Elastic and Aurora Glass; "Flat fields" is not ported (a product decision).
    • Where: src/theme/builtin-themes.ts.
    • Web: ab67d9b0, lib/theme-flat-fields.ts.
    • Suspected.
  • P3 · M · missing: no settings search. Fixed in 46e285b.
    • 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. Fixed in 8618abf.
    • 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. Fixed in ccaa24e.
    • 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. Fixed in a3d3af7.
    • 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. Fixed in cb6ddd6.
    • 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. Fixed in 6f0abb4, 746572c, 5237848; the release key, rotation lineage and secrets still have to be created before the next release (docs/android-release.md).
    • 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. Fixed in d89e331.
    • 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. Fixed in 235f4ea.
    • 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. Open: needs a one-time code exchange on the webmail side; no app change in this pass.
    • 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. Partly: 95c24a4 makes notifications honour the Sender Favicons switch; with it on (still the default) the phone fetches from DuckDuckGo.
    • 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. Fixed in cb6ddd6, 5f3f584.
  • 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. Fixed in 5c7301b, 9c31de2.
  • docs/parity/05-calendar.md:268, task edit/toggle: completing fails (B12). Verified. Fixed in dcdda5e.
  • 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. Fixed in cd4ce9d, 06e8942, ee652d3.
  • 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. Fixed in 4eb8c47; the parity line is corrected.
  • docs/parity/02-mail-list-folders.md:244, search all folders (#788): scope is one account (#1082 above). Verified. Fixed in ee50418.
  • docs/parity/02-mail-list-folders.md:187, $junk/$notjunk flip: the viewer's buttons still do a plain move. Suspected. Fixed in 5e42c67.
  • 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. Fixed in 859770b.
  • docs/parity/04-composer-send.md:12, draft autosave: loses text offline, and iOS swipe-down bypasses the dialog (B16). Suspected. Fixed in 92b42e4.
  • docs/parity/04-composer-send.md:224, reschedule: drops Cc and Bcc (B19). Suspected. Fixed in e67947a, 7e598c5.
  • docs/parity/05-calendar.md:261, reminders: cancelled when the view moves (B17). Suspected. Fixed in 4b903ed.
  • docs/parity/06-contacts.md:259, trusted-senders book: trusts every contact (Security). Suspected. Fixed in d89e331.
  • 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. Fixed in ccaa24e, d8046a8.
  • docs/parity/02-mail-list-folders.md:217, date-locale: the tick claims a dateLocale setting that doesn't exist. Suspected. Open: there is still no dateLocale setting; the parity item is unticked again.

Unticked, but done or obsolete

Applied to docs/parity/ in the 2026-09-24 fix pass: the done items are ticked, the obsolete ones moved to the N/A lists, and the wrong text corrected.

  • Done (tick them):
    Item Where it landed
    01:109 remove a non-active account 6ae21d5, 490355c
    01:114 switcher error state 6ae21d5
    01:129 account cap now 10, src/lib/account-utils.ts:4 (6ae21d5)
    01:139 unified shared-account filter 6ae21d5
    02:163 permanent-delete confirm src/screens/EmailThreadScreen.tsx:361-386 (d2ed27f)
    02:168 "never" mark-as-read d2ed27f
    04:256 mailto/share intents Android; broken per B9–B11 (5802cae; fixed in cd4ce9d, 06e8942, ee652d3)
    06:200 add sender to contacts d2ed27f
    06:281 auto-trust reply recipients 5b72c4d
    07:164 quota shown in Account settings (490355c)
    09:131 contact state changes for shared accounts 26a34d5
  • 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). Fixed in e28e8b4.
#54 contact photos not used as avatars Reproduced (Viewer, "sender avatars"). Fixed in eca3871.
#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. Committed in ee04e95; not released yet.
#3 TLS client auth Partial: Android has a certificate picker (Settings → Security); iOS doesn't.
#34 sync adapters Done on Android: contacts and calendars sync both ways with the device's apps (design).
#58 encryption, #52 widgets, #51 IMAP/SMTP, #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? Decided in 5f3f584: only the page on screen auto-sends, once per message per session.
  • Settings sync (#1): needs a server-side store, which neither client has today.
  • Scope of the feature requests: encryption (#58), widgets (#52) and IMAP/SMTP (#51).

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.