Compare commits

...
216 Commits
Author SHA1 Message Date
Linus Rath 6c82130267 chore: update version number to 0.1.62 2026-08-30 00:37:37 +02:00
Linus Rath f228257ec9 fix: stop the viewer crashing on any message with attachments by keeping store selectors referentially stable 2026-08-29 22:41:09 +02:00
Linus Rath a02e7f8a49 test: add an opt-in live Stalwart integration suite 2026-08-29 22:28:42 +02:00
Linus Rath ea1f53f550 fix: keep the created message id when the server answers a send with an implicit filing update 2026-08-29 22:28:41 +02:00
Linus Rath 4e035f3011 chore: update version number to 0.1.61 2026-08-29 20:25:25 +02:00
Linus Rath e04bea451a fix: check the GitHub CLI login before bumping so a failed release does not leave a bare tag 2026-08-29 20:20:54 +02:00
Linus Rath 5cb28f5d5d chore: update version number to 0.1.60 2026-08-29 20:20:15 +02:00
Linus Rath 94c9a2e7a2 chore: update version number to 0.1.59 2026-08-29 20:19:36 +02:00
Linus Rath 3f2dfe83ee chore: merge upstream fixes for reply threading, iOS session auth drop, calendar paging and the camera prompt (#35, #38, #39, #40) 2026-08-29 20:16:33 +02:00
Linus Rath 99a20777b7 docs: tick the P1 summary and record the fix-pass status in the parity index 2026-08-29 18:56:47 +02:00
Linus Rath 96272b6e17 fix: confirm the host before opening meeting links from calendar events and invitations 2026-08-29 18:56:46 +02:00
Linus Rath 8e33808ded docs: tick the calendar parity items fixed in this pass 2026-08-29 18:55:31 +02:00
Linus Rath 2abea9742e feat: add a send-invitations switch and custom reminder offsets, keeping alerts the picker cannot show 2026-08-29 18:53:58 +02:00
Linus Rath c705227611 chore: add the harvested and hand-written RN locale keys 2026-08-29 18:49:38 +02:00
Linus Rath 5fe8e407c7 feat: duplicate, share as .ics and copy the meeting link of an event; link imported UIDs instead of skipping them (#113) 2026-08-29 18:49:08 +02:00
Linus Rath 0159d6acc8 docs: tick the outbox and unified-inbox core items 2026-08-29 18:48:51 +02:00
Linus Rath 8fd709d308 docs: record the commit hashes for the mail-list parity items 2026-08-29 18:47:21 +02:00
Linus Rath 0205aa0e1c feat: cross-account moves via blob import, shared-account offline sync, parked failed outbox ops with retry and back-off, archive replay with foldering, localized relative dates 2026-08-29 18:47:12 +02:00
Linus Rath 09c24fa805 feat: edit tasks with description, priority and due time, filter the list and overlay due tasks on the calendar 2026-08-29 18:44:36 +02:00
Linus Rath 86c3ab9f28 fix: escape the line-break regex in the quick-reply prefill 2026-08-29 18:42:26 +02:00
Linus Rath 9398b46795 docs: tick the viewer-side core items 2026-08-29 18:41:49 +02:00
Linus Rath f9ae4423e9 fix: sweep stale export files at launch and carry quick-reply text into the composer 2026-08-29 18:41:49 +02:00
Linus Rath 8e1d03c746 feat: fire calendar and task reminders as local notifications 2026-08-29 18:41:06 +02:00
Linus Rath a222e89611 refactor: merge the quick reply's email-body imports 2026-08-29 18:40:42 +02:00
Linus Rath 26786ca3e4 fix: add the reader's load-failed translation key 2026-08-29 18:40:02 +02:00
Linus Rath 6ae21d5690 feat: folder actions, tags and unified views in the drawer, folder parent/role editing, tag discovery and account-routed unified actions (#404, #498, #495, #175, #658, #855) 2026-08-29 18:39:45 +02:00
Linus Rath d6670c2b60 docs: tick the reader parity items 2026-08-29 18:39:29 +02:00
Linus Rath d2ed27fd3b feat: conversation view with collapsible cards, header details, auth chips, unsubscribe and read-receipt banners, attachment preview and zip, embedded-message and TNEF unwrap in the reader (#480, #830, #891) 2026-08-29 18:38:53 +02:00
Linus Rath ee3b3ffcc3 test: mock the Expo native modules the stores reach transitively 2026-08-29 18:36:08 +02:00
Linus Rath 0f80e98fbc feat: localize the calendar, add Saturday week start, ISO week numbers, month chips and 12h labels (#666) 2026-08-29 18:35:46 +02:00
Linus Rath 2b44936a17 chore: harvest new RN-only translation keys 2026-08-29 18:34:18 +02:00
Linus Rath bf92a00199 test: cover the draft route-param builder and tick the composer parity items 2026-08-29 18:34:09 +02:00
Linus Rath 00fd0696b2 docs: note the deferred date-locale item in the parity checklist 2026-08-29 18:33:27 +02:00
Linus Rath c659b5489c feat: signature, read-receipt, empty-subject, autosave and sub-address settings, identity Reply-To/Bcc/HTML signature with a default identity, template category/favourite, and reschedule/send-now/edit on the scheduled screen (#507) 2026-08-29 18:33:22 +02:00
Linus Rath 3a8264c7f7 feat: translate the account security pane 2026-08-29 18:33:16 +02:00
Linus Rath dc3ac4c7ed feat: translate the downloads pane 2026-08-29 18:30:32 +02:00
Linus Rath ae31a71e01 docs: tick settings pane, theme and sidebar-app items in the parity checklist 2026-08-29 18:30:09 +02:00
Linus Rath 0e63d45599 feat: expose a mobile sidebar-apps selector for the drawer 2026-08-29 18:30:08 +02:00
Linus Rath 22697fd111 feat: translate the appearance, reading, composing, identity, template, keyword and sidebar-app panes 2026-08-29 18:29:34 +02:00
Linus Rath fe45a444d3 feat: port the webmail's built-in colour themes to the Themes pane 2026-08-29 18:29:18 +02:00
Linus Rath bea3fc4c42 feat: message-list order presets with the Stalwart keyword-sort polarity probe, avatars-in-junk and folder-count toggles (#718, #498) 2026-08-29 18:28:43 +02:00
Linus Rath 5b72c4d314 feat: drafts with autosave and a save/discard close guard, Bcc, signatures, templates, sub-addressing, From override, read receipts, upload progress, inline-image hydration and an undo-send snackbar in the composer (#188, #849, #848, #540, #570, #684, #163) 2026-08-29 18:28:38 +02:00
Linus Rath 3194b5ece1 feat: tags, pins, recipients, not-spam, delete confirm, folder-scoped search and draft editing in the mail list 2026-08-29 18:23:08 +02:00
Linus Rath f07e039ef0 feat: flip $junk/$notjunk on spam, search all folders by default, keep the search across folders and coalesce refreshes in the mail store (#850, #788, #553, #175) 2026-08-29 18:22:14 +02:00
Linus Rath 8206893860 feat: assess invitation trust, read the iTIP method from the ICS and pick the target calendar in the invitation banner 2026-08-29 18:22:00 +02:00
Linus Rath 65c73d82a1 docs: tick misc UI items in the parity checklist 2026-08-29 18:21:34 +02:00
Linus Rath 1d1ff35d1b chore: harvest new RN-only translation keys 2026-08-29 18:21:17 +02:00
Linus Rath 18e56163de test: compare locale coverage against the vendored catalog and add missing overlay keys 2026-08-29 18:20:47 +02:00
Linus Rath 3ae9bbe702 feat: add a toast host, prompt dialog, haptics helper and reduce-motion aware animations 2026-08-29 18:20:01 +02:00
Linus Rath 5802caeea9 feat: open deep links, mailto: URLs and share-sheet payloads in the app 2026-08-29 18:20:01 +02:00
Linus Rath 06742ef7d5 fix: render HTML-only mail as HTML, keep stylesheets and block every external-content vector in the reader (#46, #49) 2026-08-29 18:15:22 +02:00
Linus Rath dae5700688 feat: manage calendars from the drawer and scope, edit and auto-refresh iCal subscriptions 2026-08-29 18:14:48 +02:00
Linus Rath 42ab246cb9 feat: port recipient parsing, reply-identity, sub-addressing, signature, quote-header and template helpers; drop the dead submission api 2026-08-29 18:12:15 +02:00
Linus Rath 2bf2e89813 docs: tick account, security and push items in the parity checklists 2026-08-29 18:11:28 +02:00
Linus Rath ddae1e63f2 feat: manage public keys and encryption at rest from account security 2026-08-29 18:11:13 +02:00
Linus Rath 490355c65e feat: list logged-in accounts, storage quota and auth method in account settings 2026-08-29 18:11:13 +02:00
Linus Rath 1f2df5f079 fix: run the push background task on a detached JMAP session instead of re-binding the client 2026-08-29 18:11:13 +02:00
Linus Rath 2586eccaca docs: tick the Android backup item 2026-08-29 18:11:08 +02:00
Linus Rath 7f9f5af7e6 fix: keep the mail cache and outbox out of Android auto-backup 2026-08-29 18:11:07 +02:00
Linus Rath 78532211f3 docs: tick the login localization items 2026-08-29 18:10:25 +02:00
Linus Rath 240471764a feat: localize the sign-in flow and offer previously used addresses 2026-08-29 18:10:25 +02:00
Linus Rath 63bf3921b0 fix: only open safe URL schemes from contact cards 2026-08-29 18:07:15 +02:00
Linus Rath 56f8dceb8f docs: tick the auth and JMAP core parity items 2026-08-29 18:06:48 +02:00
Linus Rath 0d94d7643c fix: finish a webmail hand-off with a TOTP code instead of failing 2026-08-29 18:06:48 +02:00
Linus Rath 99eb525d22 docs: tick the contacts parity items 2026-08-29 18:05:59 +02:00
Linus Rath 58e3ef6827 fix: query events by date window, scan tasks separately and stop re-probing denied shared accounts (#907) 2026-08-29 18:05:56 +02:00
Linus Rath 4bebf70482 feat: sign in with OAuth (PKCE) directly against the mail server and revoke refresh tokens on sign-out 2026-08-29 18:05:16 +02:00
Linus Rath 0e514659be fix: query the server for a contact's upcoming events instead of the calendar cache 2026-08-29 18:04:48 +02:00
Linus Rath 78114b620a fix: show and remove synced trusted senders in settings and add the address-book sync toggle (#730) 2026-08-29 18:04:47 +02:00
Linus Rath bc4c57e734 feat: set a default address book from settings and guard it from deletion 2026-08-29 18:04:47 +02:00
Linus Rath 358bdb93ea feat: bucket the contact index by any letter, add select-all/export, a shared-from drawer section and category rename 2026-08-29 18:04:47 +02:00
Linus Rath 8bf48f6465 docs: tick settings-store and updater items in the parity checklist 2026-08-29 18:04:44 +02:00
Linus Rath 431facef0b feat: add update severity, quiet rate-limit backoff and lazy checksum fetch to the updater 2026-08-29 18:04:21 +02:00
Linus Rath 3d0d440673 refactor: drop dead mail layout, S/MIME and plugin controls from settings 2026-08-29 18:04:21 +02:00
Linus Rath 8deff66274 feat: validate settings on hydrate, align defaults with the webmail and add export/import/reset 2026-08-29 18:04:21 +02:00
Linus Rath a071d0581c feat: refetch filters and vacation on push and fan state changes out to screens 2026-08-29 18:03:24 +02:00
Linus Rath 6f747db858 fix: resolve redirects in JS for client-certificate requests and pass blob deadlines to the native path (#627) 2026-08-29 18:03:24 +02:00
Linus Rath 69bedf0b26 feat: manage public keys and encryption at rest through the Stalwart API 2026-08-29 18:03:23 +02:00
Linus Rath ce97232eb2 fix: allow cleartext server URLs only on local hosts, name the mail domain as TOTP issuer and guard external links 2026-08-29 18:03:23 +02:00
Linus Rath 516c9c931a fix: only drop exact role-folder name duplicates from the folder tree (#771) 2026-08-29 18:02:37 +02:00
Linus Rath c8be383c05 fix: page the viewer over the list a unified-inbox message was opened from 2026-08-29 18:01:53 +02:00
Linus Rath 9ae91a6ab6 docs: tick the filters, vacation and files parity items 2026-08-29 18:00:36 +02:00
Linus Rath c0e1d6b0a8 feat: files screen with search, sort, move/duplicate, multi-file upload progress, batch download, thumbnails and localization 2026-08-29 17:59:44 +02:00
Linus Rath a0ce9258cd feat: files API reads modified dates, decodes WebDAV names, copies/moves nodes and streams uploads (#700, #869, #563) 2026-08-29 17:59:44 +02:00
Linus Rath 1cef53af5a feat: add contacts to groups from the detail sheet, dedupe group recipients and drop the UID when duplicating 2026-08-29 17:59:11 +02:00
Linus Rath 9800ad07c2 feat: rebuild the contact form around PartialDate, org cards, group editing and cleared-field patches (#224, #307, #701) 2026-08-29 17:58:54 +02:00
Linus Rath 3fd5f10afe fix: gate event editing on calendar rights and keep read-only calendars out of the pickers (#762) 2026-08-29 17:57:54 +02:00
Linus Rath edc26ce1ca fix: reconnect live updates after network blips, token refresh, account switch and app resume 2026-08-29 17:57:33 +02:00
Linus Rath 032732cea1 fix: check the account-level Stalwart capability and keep the stored password in step after a change (#47) 2026-08-29 17:57:33 +02:00
Linus Rath b52768e6fb fix: keep the live session on a failed add or switch, recover from revoked sessions, refresh display names and sign in with a TOTP code (#900) 2026-08-29 17:57:32 +02:00
Linus Rath e70e57bf47 docs: tick push notification items in the parity checklist 2026-08-29 17:54:17 +02:00
Linus Rath f6f26f7950 feat: add push device list, re-register, onboarding prompt and an i18n key harvester 2026-08-29 17:53:59 +02:00
Linus Rath 00faceb25f fix: subscribe to EmailDelivery only, use the relay's email ids and route pushes per account (#45) 2026-08-29 17:53:53 +02:00
Linus Rath 7f956d60f7 fix: reply to Reply-To, drop own addresses on reply-all, thread with the Message-ID and keep attachments on forward (#234, #703) 2026-08-29 17:52:34 +02:00
Linus Rath 5dc40706fa fix: send invitations with a server-scheduled organizer and match RSVPs against identities and aliases (#731) 2026-08-29 17:52:26 +02:00
Linus Rath 26a34d5900 feat: list shared address books, gate first-touch contact loads and add recent-recipient autocomplete data (#907) 2026-08-29 17:50:44 +02:00
Linus Rath 580e010b33 fix: check ContactCard/set results, re-fetch created cards, assign UIDs and paginate contact queries (#644) 2026-08-29 17:50:44 +02:00
Linus Rath ccbe67c1bc fix: check JMAP method errors in every mail call, split requests to server limits and file sent mail through Drafts (#188, #234, #850) 2026-08-29 17:49:23 +02:00
Linus Rath 506a4b1a84 feat: port the reply-recipient, subject-prefix, threading and mailbox-parsing helpers from the webmail 2026-08-29 17:49:23 +02:00
Linus Rath b538a0e53d feat: vacation responder sends UTC dates, edits an HTML body and is fully localized 2026-08-29 17:48:18 +02:00
Linus Rath 58a6f45c15 fix: save all-day events with the right length, a time zone and explicit nulls for cleared fields (#805) 2026-08-29 17:45:43 +02:00
Linus Rath feb4affea6 docs: tick i18n locale items in the parity checklist 2026-08-29 17:44:22 +02:00
Linus Rath 3ffcef2ec1 feat: actionable vacation banner, expanded IF/THEN rule view and capability-gated filter tabs 2026-08-29 17:44:14 +02:00
Linus Rath 990cd84ee3 feat: add ICU interpolation, all 27 webmail locales and a key-parity test to i18n 2026-08-29 17:44:03 +02:00
Linus Rath 0b1c240802 fix: harden the JMAP client with request deadlines, retries, rate-limit state and method-error helpers (#702, #780, #907) 2026-08-29 17:42:32 +02:00
Linus Rath 2c0dbd15d1 fix: use a CSPRNG for OAuth state, TOTP secrets and UUIDs and validate handoff endpoints 2026-08-29 17:42:31 +02:00
Linus Rath 161baf1708 fix: edit or delete a single occurrence or the rest of a series instead of the whole series 2026-08-29 17:42:22 +02:00
Linus Rath 1c6aa680c5 feat: port the extended Sieve rules (attachment field, multi-value conditions) from the webmail 2026-08-29 17:39:43 +02:00
Linus Rath e650cb2d0a fix: import vCard dates as RFC 9553 PartialDate and map vendor X- extensions (#224, #701) 2026-08-29 17:37:44 +02:00
Linus Rath eb1dd6c844 feat: sort the mail list oldest-first or newest-first (#5) 2026-08-29 17:31:16 +02:00
Linus Rath fb4796e188 ci: run typecheck and unit tests on push and pull request 2026-08-29 17:31:00 +02:00
Linus Rath b84d4d8e9e fix: mock netinfo in the vitest setup so the auth-store suite loads 2026-08-29 17:30:59 +02:00
Linus Rath eaa8249e76 docs: add webmail parity checklist 2026-08-29 17:30:37 +02:00
Linus Rath a63ba7cb81 Merge pull request #35 from lucletoffe/fix/reply-threading-msgid
fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id
2026-08-06 16:25:28 +02:00
Linus Rath beec3322b8 Merge pull request #39 from koolninad/fix/jmap-session-ios-auth-drop
fix(jmap): request /jmap/session directly to survive iOS auth-drop on redirect
2026-08-06 16:25:12 +02:00
Linus Rath 21bc02709a Merge pull request #40 from lucletoffe/fix/calendar-date-paging
fix(calendar): page a server-side date filter through CalendarEvent/query
2026-08-06 12:26:24 +02:00
lucletoffe 32b5298cdd fix(calendar): page a server-side date filter through CalendarEvent/query
queryEvents used to ignore its after/before arguments and ask Stalwart
for every event in the account with limit:1000. When an account holds
more than one page of events, the response was silently capped and the
grid rendered empty for the visible month — the missing events sat past
the truncation, never fetched. Meanwhile any recurring master whose
base start predates the window was returned exactly the same way, so
the client-side recurrence expander (already wired) never saw it.

Now the query sends after/before as a real filter ANDed with the
inCalendar restriction, and iterates position by page size until a
short page comes back (or 20 pages, as a bug backstop). Stalwart's
matcher accepts a master when any occurrence overlaps the window, so
the expander gets the raw event and produces the visible occurrences.

The refactor factors buildEventFilter() so the calendar / no-calendar
and window / no-window combinations produce the right filter shape
(single condition, AND, or absent). Tests updated to lock the new
filter shape and to cover the paging path (regression: 1000 events
that used to become 1000, now 1002).

Picked up from Jim Preston's uncommitted diff in the sister worktree
during the 2026-08-03 diagnostic session (artifact PkOWkT8C).
2026-08-06 09:20:40 +02:00
Ninad Chandorkar da1ff62725 fix(jmap): request /jmap/session directly to survive iOS auth-drop on redirect
Signing in from the iOS build against a Stalwart server fails with "No
account found in JMAP session".

Stalwart's discovery endpoint /.well-known/jmap answers with a 307 to
/jmap/session. iOS NSURLSession strips the Authorization header when it
auto-follows that redirect, so the app gets an unauthenticated session
back — HTTP 200, empty accounts — and resolveAccountId throws. Browsers
keep the auth header on same-origin redirects, which is why the webmail
never saw this; it only bites the native app on iOS.

Request /jmap/session directly so the header stays attached, falling
back to /.well-known/jmap on 404 for servers that don't expose the
Stalwart path.

Also treat a 200 with no accounts as an auth failure. Stalwart answers
that way for missing or invalid credentials instead of sending a 401, so
a user who fat-fingers their password currently gets "No account found
in JMAP session" rather than "Invalid credentials".

Tests updated for both behaviours; full suite passes (the pre-existing
auth-store.test.ts parse failure on main is unrelated).
2026-08-05 23:34:53 +05:30
Linus Rath 077b590851 Merge pull request #38 from koolninad/fix/camera-prompt-and-drawer-first-open
fix: camera pre-prompt wording (App Store 5.1.1(iv)) and drawer first open
2026-08-05 18:39:36 +02:00
Ninad Chandorkar 71af9d105d fix: camera pre-prompt wording (App Store 5.1.1(iv)) and drawer first open
Two fixes App Review and testers hit on the iOS build of this app.

**Camera pre-prompt.** App Review rejects a custom pre-permission screen
whose button says "Allow" under guideline 5.1.1(iv) — it reads as an
imitation of the system dialog. Verbatim from the rejection: "A custom
message appears before the permission request, and to proceed users press
an 'Allow' button. Use words like 'Continue' or 'Next' on the button
instead."

The QR scanner's permission screen now says "Continue", and the copy
above it explains what the camera is for instead of asking for access.
When the OS stops allowing a re-prompt (canAskAgain is false) the button
becomes "Open Settings" and calls Linking.openSettings(), which is the
recovery path the same guideline suggests.

**Drawer first open.** The sidebar drawers park at -screenWidth and start
the slide-in from the effect that watches `visible`. That effect runs
before the Modal's native view is attached, so the very first open loses
its animation and the drawer renders almost entirely off-screen; opening
it again works because the view now exists. The open animation also runs
from the Modal's onShow, which commits the final offset once the modal is
really on screen and is a no-op on later opens. Applied to the mail,
calendar and contacts drawers, which all share the pattern.
2026-08-05 21:52:13 +05:30
Linus Rath 82b97fada4 chore: update version number to 0.1.58 2026-08-02 20:55:51 +02:00
Linus Rath 428a1bf03d chore: update version number to 0.1.57 2026-08-02 20:54:30 +02:00
Linus Rath 09350dd1f0 refactor: polish sign-in flow UI and theme-aware QR scanner 2026-08-02 20:54:21 +02:00
Linus Rath d2d13a37c9 fix: only say "Found your mail server" when discovery found it 2026-08-02 20:17:57 +02:00
Linus Rath be82bfe814 feat: show shared mailboxes (group accounts) in the sidebar #37 2026-08-02 20:17:29 +02:00
Linus Rath 5c775ad82e Merge pull request #31 from arnauda-gh/fix/swipe-action-desync
fix(ui): synchronize swipe action gesture handlers with latest props
2026-08-02 16:29:07 +02:00
Linus Rath 941e5ede12 refactor(ui): move swipe gesture decisions into a pure module
Keeps the fix — gesture handlers now read current props — but makes the
class of bug structurally impossible rather than patched case by case.

The PanResponder is created once, so its handlers closed over the props of
the render that built it. Four call sites had to be corrected individually,
and nothing stopped a fifth from being added stale. Now the decisions live
in swipe-gesture.ts as pure functions taking the config as an argument, so
there is nothing left for a handler to capture; the component keeps one
`latest` ref pointing at the current props and hands it over on each event.

Also:
- Replace the placeholder test, which asserted that assigning to a locally
  declared `{current}` object works and passed against the unfixed
  component, with 20 tests over the real gesture logic: claim/decline,
  overshoot clamping, commit thresholds, flick velocity, reveal open/close
  from an offset base, and the 'none' cases.
- Build the responder in a useMemo instead of re-running PanResponder.create
  on every render of every visible row and discarding the result.
- Fold the four repeated Animated.spring calls into springTo/settle.

Beyond the reported symptom, the same staleness broke two more cases:
entering selection mode passes 'none' for both actions, but a swipe still
fired the old action; and onAction captured a handleSwipeAction from before
the mailbox ids resolved, so on a slow connection swipe silently did nothing
without anyone touching the setting — which matches the deeper problem
Arnaud suspected on the PR.
2026-08-02 16:28:48 +02:00
Linus Rath 2577f4e64b Merge pull request #33 from arnauda-gh/fix/attachment-direct-view
fix(android): open attachments directly with default viewer app
2026-08-02 15:37:03 +02:00
Linus Rath ee21c15fec refactor(android): tidy the attachment direct-view handoff
Follow-up to the direct-view fix. No behaviour change on the happy path,
but the handoff is now easier to reason about and covered by tests.

- Use `File.contentUri` from the expo-file-system 19 API instead of the
  legacy `getContentUriAsync`, dropping the `expo-file-system/legacy`
  import. Same FileProvider, one less round trip through the bridge.
- Pull the intent launch into `openWithViewer`, which returns false when
  the handoff didn't happen, so the fallback reads as a decision rather
  than as an empty catch. It also no longer swallows the reason silently.
- Rewrite the expo-sharing comment above `shareAttachment`: it warns
  against pre-translating to a content URI, which is still true for every
  `Sharing.shareAsync` call but is exactly what the new VIEW path has to
  do. Note why `downloadAttachment` keeps the share sheet as well.
- Record why FLAG_ACTIVITY_NEW_TASK is deliberately absent: the module
  launches via startActivityForResult, which a new task would cancel.
2026-08-02 15:36:31 +02:00
Linus Rath e574fd9e11 chore: update version number to 0.1.56 2026-08-01 14:25:31 +02:00
Linus Rath dfa278f7a8 fix: sign iOS archives manually with an App Store provisioning profile 2026-08-01 13:47:49 +02:00
Linus Rath 4eeda34d95 fix: sign iOS archives with the distribution identity 2026-08-01 13:37:34 +02:00
Linus Rath 22ad291202 Merge branch 'main' of https://github.com/bulwarkmail/native 2026-08-01 13:29:18 +02:00
Linus Rath f040a48e2f feat: iOS TestFlight release pipeline 2026-08-01 13:29:15 +02:00
Linus Rath c29cbcd549 feat: step-by-step sign-in with server discovery from the email address 2026-08-01 13:28:31 +02:00
Linus Rath bf67b97176 Merge pull request #36 from lucletoffe/fix/unread-dot-anchor
fix(list): anchor the unread dot on the first line, not the row midpoint
2026-07-31 21:15:30 +02:00
lucletoffeandClaude Opus 5 93761e67ef fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id
Replying put `email.id` — the server-local JMAP handle, e.g. `gos2aabtux` —
into In-Reply-To/References. No recipient can match that against any
Message-ID, so the conversation splits in two: the original sits alone in
its thread while the reply (and everything answering it) starts a fresh one.

Proven against a live Stalwart account: the reply carried
`In-Reply-To: gos2aabtux` and landed in thread `btuz` while its parent stayed
in `btux` — Stalwart hashes the referenced msg-ids at ingest and found no
match.

Fetch `messageId`/`inReplyTo`/`references` with the full email and build the
headers from them (bracketed, ancestors first, parent closing the chain).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb
2026-07-31 12:39:01 +02:00
lucletoffeandClaude Opus 5 a2484f3508 fix(list): anchor the unread dot on the first line, not the row midpoint
The dot added in #27 is positioned with `top: '50%'`, so it centres on the
whole row. Rows are three lines tall (sender / subject / preview), so the dot
lands a full line below the checkbox and the avatar it reads as a column
with — the taller the row, the worse it looks.

Anchor it on the first line instead: row padding + half an avatar, following
the density (and the text line height when the avatar is hidden in
extra-compact). The webmail's list carries the same `top-1/2` and the same
drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb
2026-07-31 12:39:01 +02:00
Linus Rath 82997e27e4 docs: fix doubled slash in logo URLs 2026-07-25 18:23:17 +02:00
arnauda-gh b7995fafde fix(android): open attachments directly with default viewer app
Fixes #32.

On Android, Sharing.shareAsync triggers Intent.ACTION_SEND (Share sheet). Updated shareAttachment to convert the file URI to a content URI and launch android.intent.action.VIEW via expo-intent-launcher, with fallback to shareAsync if no viewer application is installed for the MIME type.
2026-07-24 12:03:28 +02:00
arnauda-gh cde461999f fix(ui): synchronize swipe action gesture handlers with latest props
Fixes #30.

PanResponder in SwipeableRow was created once on mount and captured initial props in a stale closure. Using mutable refs ensures gesture handlers always read and execute the latest leftAction, rightAction, mode, and onAction props.
2026-07-24 11:52:07 +02:00
Linus Rath 802e1e1c71 fix: send-time editor readback, base-view restore after search, Stalwart recurrence #9, #10, #13 2026-07-22 22:17:40 +02:00
Linus Rath 2427bdbd79 fix: keep VTODO-only task lists out of the Calendars drawer #28
Stalwart exposes task lists (supported-calendar-component-set = VTODO)
as sibling CalDAV collections, so Calendar/get returns them mixed in
with event calendars and the drawer showed confusing duplicate entries
(Todoist Inbox, per-project task lists colliding with same-named
event calendars).

JMAP Calendar/get doesn't surface the CalDAV component set, so the
store now infers the collection kind from its contents: a calendar
whose objects are all Tasks (>=1 task, no events) is flagged as a task
list. Flagged calendars are excluded from the Calendars drawer, the
event-creation calendar picker, and the ICS import target picker, and
sorted first in the Tasks sheet so new tasks default into a dedicated
task list. Empty calendars and mixed event+task calendars are
untouched.
2026-07-22 21:37:27 +02:00
Linus Rath 05e2af23dd fix: add unread indicator dot to email list and unified inbox rows #27
The only unread cues were a font-weight bump and a grey-to-white shift
on the sender name, which barely render on some device fonts and are
nearly invisible in the dark theme. Mirror the webmail's dedicated
indicator (email-list-item.tsx): an 8px circle in the theme's existing
unread color, vertically centered in the row's start gutter, shown in
both the mailbox list and the unified inbox.

Verified on a Pixel 7 API 34 emulator in dark theme: unread rows are
unambiguous at a glance, read rows unchanged.
2026-07-21 20:17:41 +02:00
Linus Rath 42f18a78b2 chore: update version number to 0.1.54 2026-07-21 19:55:27 +02:00
Linus Rath 406d2c208a Merge origin/main: keep escape-free inline editor over asset-split fix
PR #20 (c5e5092) fixed #11 on Android by moving the editor page to
android/app/src/main/assets/editor.html, but the real bug was never
'webview scripting broken in release builds': the template literal in
buildEditorHtml cooked the insertLink scheme-regex escapes into an
invalid regex, and that SyntaxError killed the whole inline script.
The PR's buildEditorScript (injectedJavaScript) still contains the
cooked regex, so its theme/init injection dies on Android and the
editor stays completely dead on iOS, whose page no longer has any
inline script.

Resolve by keeping the lib-based editor from the local fix (valid,
escape-free inline script for both platforms, regression-tested in
src/lib/__tests__/editor-html.test.ts, verified on a Pixel 7 API 34
release build) and dropping the now-unused editor.html asset. All
other upstream fixes are kept.
2026-07-21 19:46:55 +02:00
Linus Rath 6f712bf43f fix: compose editor inline script died on parse, keeping Send disabled #11
The template literal in buildEditorHtml cooked the backslash escapes in
the insertLink scheme regex (\/ and \? became bare / and ?), so the page
received /^(https?:|mailto:|tel:|sms:|/|?|#)/i — an invalid regex. The
resulting SyntaxError killed the entire inline editor script: no change
messages ever reached RN, bodyHtml stayed empty, canSend never enabled
without an attachment, and mails sent with one had empty bodies.
Regressed in 29b06a3.

Rewrite the scheme check without backslash escapes and move the page
builder into src/lib/editor-html.ts so a unit test can assert the emitted
script parses and stays backslash-free.

Verified on a Pixel 7 API 34 emulator (release build): typing now posts
change messages and the plain-text body extraction sees the content.
2026-07-21 19:34:51 +02:00
Linus Rath 844cb703c8 Merge pull request #20 from arnauda-gh/fix/android-webview-release-editor
fix(editor): resolve disabled send button and webview scripting on android release builds
2026-07-19 10:12:31 +02:00
Linus Rath 11e5963149 Merge pull request #26 from arnauda-gh/fix/contact-suggestions-view
fix(composer): improve contact suggestion layout and prevent focus/duplicate issues
2026-07-16 19:32:33 +02:00
arnauda-gh 07438e010e fix(composer): improve contact suggestion layout and prevent focus loss & duplicate recipients 2026-07-16 19:05:47 +02:00
Linus Rath 3e33efb71f Merge pull request #17 from arnauda-gh/fix/snackbar-zindex
fix: ensure UndoSnackbar renders on top of compose FAB
2026-07-16 17:57:55 +02:00
Linus Rath 376ee3d678 Merge pull request #18 from arnauda-gh/fix/sender-addresses-scroll
fix: replace identity alert picker with scrollable bottom sheet modal
2026-07-16 17:57:31 +02:00
Linus Rath 6f19cdbd17 Merge pull request #22 from arnauda-gh/fix/duplicate-recipients
fix(composer): prevent duplicate recipients from being added on done/blur action
2026-07-16 17:55:18 +02:00
Linus Rath 6985a7d5f6 Merge pull request #24 from arnauda-gh/fix/swipe-actions-notification
fix(mailboxes): prevent silent swipe failures by notifying user of missing folders
2026-07-16 17:55:05 +02:00
arnauda-gh 8231a55acb fix(mailboxes): show error notifications when special folders are missing on JMAP server 2026-07-16 10:27:30 +02:00
arnauda-gh 1b0740eab4 fix(composer): prevent duplicate recipients from being added on done/blur action 2026-07-16 10:15:04 +02:00
arnauda-gh c5e509274d fix(editor): resolve disabled send button and webview scripting on android release builds 2026-07-16 10:08:17 +02:00
arnauda-gh 1bf5920b04 fix: pre-select primary sender identity matching active account email 2026-07-15 19:43:34 +02:00
arnauda-gh 2547c6f199 fix: replace identity alert picker with scrollable bottom sheet modal 2026-07-15 19:31:54 +02:00
arnauda-gh 69e27a386c fix: ensure UndoSnackbar renders on top of compose FAB 2026-07-15 18:49:52 +02:00
Linus Rath 7390096567 Merge pull request #15 from arnauda-gh/fix/coalesce-token-refresh
fix(auth): coalesce concurrent token refresh requests
2026-07-13 21:18:13 +02:00
arnauda-gh 58c642f27b fix(auth): coalesce concurrent token refresh requests
Coalesces concurrent OAuth token refreshes using a Promise-based lock map keyed by the refresh token. This prevents the app from firing duplicate refresh requests with the same expired refresh token, which causes replay revocations on OIDC providers (e.g. Authelia/Hydra) enforcing strict rotation.

Also optimizes persistRefreshedTokens and ensureFreshCredentials to prevent redundant SecureStore/disk writes when concurrent operations complete.

Fixes #14
2026-07-13 09:05:45 +02:00
Linus Rath b1d1e84666 chore: update version number to 0.1.53 2026-06-17 09:40:10 +02:00
Linus Rath 55484453a1 fix: coalesce concurrent push setups to prevent subscription swarm 2026-06-17 09:39:20 +02:00
Linus Rath b525c52870 chore: update version number to 0.1.52 2026-06-17 09:07:55 +02:00
Linus Rath b64afb462f fix: reap dead push subscriptions from other devices via relay liveness check 2026-06-17 09:07:42 +02:00
Linus Rath b92f85a236 chore: update version number to 0.1.51 2026-06-14 14:46:22 +02:00
Linus Rath 643d704b74 fix: namespace shared calendar ids to avoid collisions with own calendars 2026-06-14 14:46:15 +02:00
Linus Rath 9e383c3616 chore: update version number to 0.1.50 2026-06-14 13:43:36 +02:00
Linus Rath 1543db49d1 fix: match webmail event display logic 2026-06-14 13:43:27 +02:00
Linus Rath 910e5418e9 chore: update version number to 0.1.49 2026-06-12 17:16:03 +02:00
Linus Rath a019d9e775 chore: update version number to 0.1.48 2026-06-12 17:15:53 +02:00
Linus Rath e6d47b6a16 feat: shared calendars, custom recurrence editor, and file sharing 2026-06-12 17:15:50 +02:00
Linus Rath b10f61a642 chore: update version number to 0.1.47 2026-06-12 15:26:57 +02:00
Linus Rath 752aa719d8 feat: pinch zoom and pan for email body content 2026-06-12 15:26:42 +02:00
Linus Rath 5884a18979 refactor: move date/time/size meta from subject row to sender block 2026-06-11 16:33:59 +02:00
Linus Rath 3ccc83e7ce chore: update version number to 0.1.46 2026-06-11 16:24:55 +02:00
Linus Rath 5dc4ea1a45 refactor: replace three-pane pager with FlatList page-snapping pager 2026-06-11 16:24:43 +02:00
Linus Rath 1bd476e052 chore: update version number to 0.1.45 2026-06-11 15:11:28 +02:00
Linus Rath 41793455f7 fix: seamless email swipe transitions with skeleton loading placeholder 2026-06-11 15:11:09 +02:00
Linus Rath d99cbf1dbb chore: update version number to 0.1.44 2026-06-11 14:39:39 +02:00
Linus Rath 1afd2185d5 feat: three-pane pager for seamless email swipe navigation 2026-06-11 14:39:30 +02:00
Linus Rath 5aa1c88b1e chore: update version number to 0.1.43 2026-06-05 19:25:47 +02:00
Linus Rath ac95a2bc39 feat: QR code sign-in 2026-06-05 19:25:00 +02:00
Linus Rath e75aefeb4f chore: update version number to 0.1.42 2026-06-05 16:38:13 +02:00
Linus Rath 70863e0923 feat: swipe navigation between emails and dark-mode emoji/background re-inversion 2026-06-05 16:35:06 +02:00
Linus Rath b6ecf07369 feat: customizable reader quick actions and shrink-to-fit email scaling 2026-06-05 16:21:00 +02:00
Linus Rath bf5d75d5e9 chore: update version number to 0.1.41 2026-05-30 17:25:51 +02:00
Linus Rath 53520a4236 fix: prevent infinite render loop in contacts selectors 2026-05-30 17:25:47 +02:00
Linus Rath c55f78b5a7 chore: update version number to 0.1.40 2026-05-30 15:33:52 +02:00
Linus Rath 5d47f47c8e feat: add Sieve filters, account security (TOTP), outbox, and EML import 2026-05-30 15:33:41 +02:00
Linus Rath d16f04e818 chore: update version number to 0.1.39 2026-05-29 15:34:50 +02:00
Linus Rath d616943c25 feat: add unified inbox, scheduled send, iCal import/subscriptions, tasks, and contact tagging/import 2026-05-29 15:34:39 +02:00
Linus Rath cf4e3955bf chore: update version number to 0.1.38 2026-05-19 00:45:52 +02:00
Linus Rath bf20c0af58 feat: implement OAuth token handling and webmail-mediated login flow 2026-05-19 00:45:42 +02:00
Linus Rath 67f7f83621 chore: update version number to 0.1.37 2026-05-19 00:08:27 +02:00
Linus Rath 086d398058 feat: implement webmail-mediated login flow and remove OAuth dependencies 2026-05-19 00:05:51 +02:00
Linus Rath 373a1a60d8 chore: update version number to 0.1.36 2026-05-18 23:32:59 +02:00
Linus Rath f71f219187 feat: implement OAuth login flow and enhance authentication handling 2026-05-18 23:32:50 +02:00
Linus Rath 77b7be03d8 chore: update version number to 0.1.35 2026-05-18 21:52:49 +02:00
Linus Rath 9dd9ce8105 feat: enhance email store and offline cache to support account switching and data isolation 2026-05-18 21:52:35 +02:00
Linus Rath dd64931be8 chore: update version number to 0.1.34 2026-05-18 20:11:20 +02:00
Linus Rath ac234f288e Refactor email store to support incremental sync 2026-05-18 20:05:03 +02:00
Linus Rath 81749dd89c chore: update version number to 0.1.33 2026-05-15 17:26:10 +02:00
Linus Rath beef086d4c chore: update version number to 0.1.32 2026-05-15 17:26:00 +02:00
Linus Rath cfd67398de feat: add capability hooks for calendar, contacts, and files 2026-05-15 17:25:55 +02:00
Linus Rath 2b15d2dc50 chore: update version number to 0.1.31 2026-05-15 02:10:55 +02:00
Linus Rath 9bf2ecdf27 fix: resolve "Not authenticated" race on cold start and account switch 2026-05-15 00:31:57 +02:00
Linus Rath 67db8a2ac3 chore: update version number to 0.1.30 2026-05-14 13:51:00 +02:00
Linus Rath 6846d859fd feat: implement multi-account support for push notifications and enhance teardown logic 2026-05-14 13:50:52 +02:00
Linus Rath 3bdca0133e chore: update version number to 0.1.29 2026-05-14 12:57:18 +02:00
Linus Rath 1858d08dd0 feat: enhance notification handling with accountId support and improve file management functions 2026-05-14 12:57:03 +02:00
Linus Rath 427d163dc0 chore: update version number to 0.1.28 2026-05-08 20:04:32 +02:00
Linus Rath 7e34ba42ef feat: implement in-memory upload with uploadBytes function and integrate into createFolder 2026-05-08 20:04:25 +02:00
Linus Rath 886a64171d chore: update version number to 0.1.27 2026-05-08 13:18:24 +02:00
Linus Rath 760bf01f4d fix: defer expo-document-picker require to avoid native-module crash 2026-05-08 13:18:17 +02:00
370 changed files with 123042 additions and 8044 deletions
+26
View File
@@ -0,0 +1,26 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Unit tests
run: npm test
+274
View File
@@ -0,0 +1,274 @@
name: Release iOS (TestFlight)
# The iOS project is NOT committed - unlike android/, which is hand-maintained,
# ios/ is gitignored and generated fresh by `expo prebuild` on every run. Keep
# any native iOS change in app.config.js (or a config plugin), never in ios/.
on:
release:
types: [published]
workflow_dispatch:
inputs:
upload_to_testflight:
description: "Upload the build to TestFlight (off = build the IPA only)"
type: boolean
required: false
default: true
concurrency:
group: release-ios-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: macos-latest
timeout-minutes: 90
permissions:
contents: write
env:
EXPO_NO_TELEMETRY: "1"
# App Store Connect refuses a CFBundleVersion it has already seen for this
# marketing version, so every run needs a fresh, increasing number.
IOS_BUILD_NUMBER: ${{ github.run_number }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Show toolchain versions
run: |
xcodebuild -version
pod --version
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: pods-${{ runner.os }}-
- name: Install JS dependencies
run: npm ci
- name: Generate the iOS project
run: npx expo prebuild --platform ios --clean
- name: Check for signing credentials
id: creds
env:
CERT: ${{ secrets.IOS_DIST_CERT_BASE64 }}
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
TEAM: ${{ secrets.APPLE_TEAM_ID }}
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
echo "signed=true" >> "$GITHUB_OUTPUT"
else
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
fi
- name: Resolve workspace and scheme
id: xcode
run: |
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
echo "workspace=$WORKSPACE" >> "$GITHUB_OUTPUT"
echo "scheme=$SCHEME" >> "$GITHUB_OUTPUT"
echo "Using workspace $WORKSPACE, scheme $SCHEME"
# ── Unsigned path: a compile check only, so the workflow is still useful
# on forks and before the Apple secrets exist.
- name: Build without signing
if: steps.creds.outputs.signed != 'true'
run: |
xcodebuild build \
-workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release \
-destination 'generic/platform=iOS' \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=""
# ── Signed path ────────────────────────────────────────────
- name: Import the distribution certificate
if: steps.creds.outputs.signed == 'true'
env:
CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }}
CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
# Ephemeral runner, ephemeral keychain - this password protects nothing
# beyond the life of the job.
KEYCHAIN_PASSWORD=$(uuidgen)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
echo "$CERT_BASE64" | base64 -d > "$RUNNER_TEMP/dist.p12"
security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security -f pkcs12
rm -f "$RUNNER_TEMP/dist.p12"
# Without this, codesign triggers an interactive keychain prompt that
# nothing can answer on a headless runner.
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
# Put our keychain first in the search list, keeping the login one so
# Xcode can still reach Apple's intermediate certificates.
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g')
security find-identity -v -p codesigning "$KEYCHAIN"
- name: Install the App Store Connect API key
if: steps.creds.outputs.signed == 'true'
env:
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
run: |
# altool looks the key up by ID in this directory.
mkdir -p ~/private_keys
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
- name: Install the provisioning profile
if: steps.creds.outputs.signed == 'true'
id: profile
env:
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
# before anything inside it can be read.
PLIST=$(security cms -D -i "$PROFILE")
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
# from the profile keeps ExportOptions honest if app.config.js changes.
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
BUNDLE_ID="${APP_ID#*.}"
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
# Xcode moved the profile directory in 16; write both so this does not
# silently stop working on a runner image bump.
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
- name: Archive
if: steps.creds.outputs.signed == 'true'
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
# Manual signing, deliberately. Automatic signing always signs the
# archive with a *development* identity and relies on -exportArchive to
# re-sign it for distribution afterwards - which needs a development
# certificate and a registered device that a CI-only TestFlight
# pipeline has no reason to own. Naming the App Store profile up front
# skips that dance entirely.
xcodebuild archive \
-workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_IDENTITY="Apple Distribution" \
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
# Signing failures report a symptom ("no profiles found") rather than the
# setting that caused it, and a re-run costs ~30 minutes. Dump the
# resolved values instead of guessing twice.
- name: Dump signing settings
if: failure() && steps.creds.outputs.signed == 'true'
continue-on-error: true
run: |
echo "── identities in the keychain ──"
security find-identity -v -p codesigning || true
echo "── resolved build settings ──"
xcodebuild -workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release -showBuildSettings 2>/dev/null \
| grep -Ei 'CODE_SIGN|PROVISIONING|DEVELOPMENT_TEAM|PRODUCT_BUNDLE_ID' | sort -u || true
- name: Export the IPA
if: steps.creds.outputs.signed == 'true'
id: export
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PROFILE_NAME: ${{ steps.profile.outputs.name }}
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
run: |
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store</string>
<key>teamID</key><string>${TEAM_ID}</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
</dict>
<key>uploadSymbols</key><true/>
<key>destination</key><string>export</string>
</dict>
</plist>
PLIST
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
-exportPath "$RUNNER_TEMP/export" \
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist"
VERSION=$(cat VERSION | tr -d '[:space:]')
SRC=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.ipa"
cp "$SRC" "$DEST"
echo "path=$DEST" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
if: steps.creds.outputs.signed == 'true'
with:
name: ${{ steps.export.outputs.path }}
path: ${{ steps.export.outputs.path }}
if-no-files-found: error
- name: Upload to TestFlight
# The IPA is not attached to the GitHub release: without an Apple device
# provisioned for it, it is not installable, and TestFlight is the only
# distribution channel for it.
if: >-
steps.creds.outputs.signed == 'true' &&
(github.event_name == 'release' || inputs.upload_to_testflight)
env:
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
IPA="${{ steps.export.outputs.path }}"
# Validate first - it catches missing Info.plist keys and entitlement
# mistakes in seconds, instead of via a rejection email 20 minutes later.
xcrun altool --validate-app --type ios --file "$IPA" \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
xcrun altool --upload-app --type ios --file "$IPA" \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
+281 -37
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useColorScheme } from 'react-native';
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
@@ -7,7 +7,13 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
import { startPushUpdates } from './src/api/push';
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
import { jmapClient } from './src/api/jmap-client';
import type { StateChange } from './src/api/types';
import { dispatchStateChange } from './src/lib/state-change-bus';
import { sweepStaleExportFiles } from './src/lib/email-export';
import { useFilterStore } from './src/stores/filter-store';
import { useVacationStore } from './src/stores/vacation-store';
import {
addMessageListener,
addNotificationTapListener,
@@ -15,7 +21,7 @@ import {
getInitialNotificationTap,
getStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotifications,
teardownPushNotificationsForAccount,
type NotificationTapPayload,
} from './src/lib/push-notifications';
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
@@ -31,18 +37,27 @@ import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ScheduledScreen from './src/screens/ScheduledScreen';
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore } from './src/stores/email-store';
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
import { useSettingsStore } from './src/stores/settings-store';
import { useLocaleStore } from './src/stores/locale-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
import { ToastHost } from './src/components/ToastHost';
import { getEmails } from './src/api/email';
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { useOutboxStore } from './src/stores/outbox-store';
import { runOfflineSync } from './src/lib/offline-sync';
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
import { useColors } from './src/theme/colors';
@@ -51,8 +66,21 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
const navigationRef = createNavigationContainerRef<RootStackParamList>();
function navigateToNotificationTap(payload: NotificationTapPayload): void {
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
if (!navigationRef.isReady()) return;
// The notification carries the account it was generated for. If the user
// has since switched to a different account (or had a different one active
// when the notification arrived), opening EmailThread under the active
// account would fetch the email from the wrong server and fail.
const auth = useAuthStore.getState();
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
const account = useAccountStore.getState().getAccountById(payload.accountId);
if (!account) return; // account was logged out — nothing safe to open.
await auth.switchAccount(payload.accountId);
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
}
navigationRef.navigate('EmailThread', {
emailId: payload.emailId,
threadId: payload.threadId,
@@ -60,6 +88,29 @@ function navigateToNotificationTap(payload: NotificationTapPayload): void {
});
}
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
// Android share-sheet payloads all end up here once the navigator is ready.
async function openDeepLink(link: DeepLink): Promise<void> {
await handleDeepLink(link, {
navigation: navigationRef,
resolveThreadId: async (emailId) => {
try {
const [email] = await getEmails([emailId]);
return email?.threadId ?? null;
} catch {
return null;
}
},
switchAccount: async (accountId) => {
const auth = useAuthStore.getState();
if (auth.activeAccountId === accountId) return true;
if (!useAccountStore.getState().getAccountById(accountId)) return false;
await auth.switchAccount(accountId);
return useAuthStore.getState().activeAccountId === accountId;
},
});
}
function LoadingScreen({ message }: { message: string }) {
const c = useColors();
return (
@@ -75,11 +126,17 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
const mailboxes = useEmailStore((state) => state.mailboxes);
const logout = useAuthStore((state) => state.logout);
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
const hasCalendar = useHasCalendar();
const hasContacts = useHasContacts();
const hasFiles = useHasFiles();
const disabledTabStyle = { opacity: 0.4 } as const;
return (
<View style={{ flex: 1, backgroundColor: c.background }}>
<UpdateBanner />
<OfflineCacheBanner />
<PushOnboardingPrompt />
<ToastHost />
<Tab.Navigator
screenOptions={{
headerShown: false,
@@ -136,6 +193,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={CalendarScreen}
options={{
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasCalendar) e.preventDefault();
},
}}
/>
<Tab.Screen
@@ -143,6 +207,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={ContactsScreen}
options={{
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasContacts) e.preventDefault();
},
}}
/>
<Tab.Screen
@@ -150,6 +221,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={FilesScreen}
options={{
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasFiles) e.preventDefault();
},
}}
/>
<Tab.Screen
@@ -227,25 +305,49 @@ export default function App() {
// feature enabled. Re-runs whenever the user changes the days window.
const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays);
const offlineCacheMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
const haveLiveSession = useAuthStore((s) => s.session != null);
React.useEffect(() => {
void useOfflineCacheStore.getState().hydrate();
// Attachments shared out of the app linger in the cache dir; drop the
// ones older than a day so a granted content URI can't read them forever.
void sweepStaleExportFiles();
}, []);
React.useEffect(() => {
if (!offlineCacheEnabled || !haveLiveSession) return;
// Slight delay so cold start doesn't compete with the inbox load.
const t = setTimeout(() => {
void runOfflineSync({ days: offlineCacheDays });
void runOfflineSync({ days: offlineCacheDays, maxMB: offlineCacheMaxMB });
}, 2000);
return () => clearTimeout(t);
}, [offlineCacheEnabled, offlineCacheDays, haveLiveSession]);
}, [offlineCacheEnabled, offlineCacheDays, offlineCacheMaxMB, haveLiveSession]);
// Listen for FCM messages so the app can refresh state even when woken
// from the background - the FirebaseMessagingService also posts the system
// notification so the user sees it regardless of RN runtime state.
// Drain the offline action queue (outbox) as soon as we have a live session,
// and again whenever the network comes back. The flush itself no-ops when
// there's nothing queued or the client isn't ready.
React.useEffect(() => {
if (!haveLiveSession) return;
void useOutboxStore.getState().flush();
return useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online) void useOutboxStore.getState().flush();
});
}, [haveLiveSession]);
// Foreground FCM messages: the Kotlin service skips the headless task while
// the app is visible, so feed the relay's StateChange straight into the
// stores. SSE normally beats it, but this covers the window where the SSE
// socket is down and the poll fallback has not fired yet.
React.useEffect(() => {
const unsubscribe = addMessageListener((payload) => {
console.log('[push] fcm message', payload.title);
const raw = payload?.data?.changed;
if (!raw) return;
try {
const changed = JSON.parse(raw) as Record<string, Record<string, string>>;
if (!changed || typeof changed !== 'object') return;
void useEmailStore.getState().handleStateChange({ '@type': 'StateChange', changed });
} catch {
// malformed payload - ignore
}
});
return unsubscribe;
}, []);
@@ -261,11 +363,11 @@ export default function App() {
void (async () => {
const initial = await getInitialNotificationTap();
if (cancelled || !initial) return;
navigateToNotificationTap(initial);
await navigateToNotificationTap(initial);
})();
const unsubscribe = addNotificationTapListener((payload) => {
navigateToNotificationTap(payload);
void navigateToNotificationTap(payload);
});
return () => {
@@ -274,20 +376,72 @@ export default function App() {
};
}, [isAuthenticated]);
// Deep links and share-sheet payloads. The cold-start URL / share is read
// once auth is restored so the target screen has credentials.
React.useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
const open = (link: DeepLink | null) => {
if (!link || cancelled) return;
// Give the navigator a tick to mount after the auth gate flips.
setTimeout(() => { void openDeepLink(link); }, 50);
};
void Linking.getInitialURL().then((url) => open(url ? parseDeepLink(url) : null));
void getInitialShare().then((share) => {
if (!share) return;
const link = shareToDeepLink(share);
if (link.kind === 'compose' && share.uris?.length) {
navigationRef.isReady() && navigationRef.navigate('Compose', {
prefillTo: link.to,
prefillSubject: link.subject,
prefillBody: link.body,
prefillAttachments: shareAttachments(share),
});
return;
}
open(link);
});
const urlSub = Linking.addEventListener('url', ({ url }) => open(parseDeepLink(url)));
const shareSub = addShareListener((share) => {
const link = shareToDeepLink(share);
if (link.kind === 'compose' && share.uris?.length && navigationRef.isReady()) {
navigationRef.navigate('Compose', {
prefillTo: link.to,
prefillSubject: link.subject,
prefillBody: link.body,
prefillAttachments: shareAttachments(share),
});
return;
}
open(link);
});
return () => {
cancelled = true;
urlSub.remove();
shareSub();
};
}, [isAuthenticated]);
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates. Honours the user's notification
// preference - flipping it off tears down the active subscription so the
// FirebaseMessagingService stops receiving pings for this device.
// preference - flipping it off tears down THIS account's subscription so
// notifications stop arriving for it. Other logged-in accounts keep their
// setups intact.
const emailNotificationsEnabled = useSettingsStore(
(s) => s.emailNotificationsEnabled,
);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
React.useEffect(() => {
if (!isAuthenticated || !client) return;
let cancelled = false;
const doSetup = async () => {
if (!emailNotificationsEnabled) {
await teardownPushNotifications().catch(() => undefined);
if (activeAccountId) {
await teardownPushNotificationsForAccount(activeAccountId).catch(
() => undefined,
);
}
return;
}
const relayBaseUrl = await getStoredRelayBaseUrl();
@@ -315,47 +469,135 @@ export default function App() {
cancelled = true;
unsubscribe();
};
}, [client, isAuthenticated, emailNotificationsEnabled]);
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
// Live updates (SSE with polling fallback), re-armed on every account
// switch and every re-established session — the singleton `client` object
// never changes identity, so it cannot be the dependency on its own.
// Backgrounding closes the stream (no socket + server pings while the app
// is asleep); foregrounding reconnects it and refreshes what the user is
// looking at, since events that happened in between are gone for good.
React.useEffect(() => {
if (!isAuthenticated || !client) {
if (!isAuthenticated || !client || !haveLiveSession) {
return;
}
let mounted = true;
let cleanup: (() => void) | null = null;
let handle: LiveUpdatesHandle | null = null;
let appActive = AppState.currentState !== 'background' && AppState.currentState !== 'inactive';
void (async () => {
const onStateChange = async (change: StateChange) => {
// Filters / vacation edited elsewhere (webmail, another device): refetch
// so the settings panes don't save stale state over the newer script.
const primary = (() => { try { return jmapClient.accountId; } catch { return null; } })();
const own = primary ? change.changed?.[primary] : undefined;
const extra: Promise<unknown>[] = [];
if (own?.SieveScript) extra.push(useFilterStore.getState().fetchFilters().catch(() => undefined));
if (own?.VacationResponse) extra.push(useVacationStore.getState().fetch().catch(() => undefined));
dispatchStateChange(change);
await Promise.all([
useEmailStore.getState().handleStateChange(change),
useContactsStore.getState().handleStateChange(change),
useCalendarStore.getState().handleStateChange(change),
...extra,
]);
};
const start = async () => {
try {
const stopPushUpdates = await startPushUpdates(client, {
onStateChange: async (change) => {
await Promise.all([
useEmailStore.getState().handleStateChange(change),
useContactsStore.getState().handleStateChange(change),
useCalendarStore.getState().handleStateChange(change),
]);
},
onError: (error) => {
console.warn(error.message);
},
const next = await startLiveUpdates({
onStateChange: (change) => { void onStateChange(change); },
onError: (error) => { console.warn('[push]', error.message); },
onFallback: (reason) => { console.warn('[push] falling back to polling:', reason); },
isActive: () => appActive,
});
if (!mounted) {
stopPushUpdates();
next.close();
return;
}
cleanup = stopPushUpdates;
handle = next;
} catch (error) {
console.warn(error instanceof Error ? error.message : 'Failed to start JMAP push updates');
}
})();
};
const refreshAfterResume = () => {
const email = useEmailStore.getState();
void email.fetchMailboxes();
if (email.currentMailboxId) void email.refreshEmails();
void useOutboxStore.getState().flush();
};
void start();
const subscription = AppState.addEventListener('change', (state) => {
const nowActive = state === 'active';
if (nowActive === appActive) return;
appActive = nowActive;
if (!nowActive) {
handle?.close();
handle = null;
return;
}
// Coming back: verify the session is still alive before trusting the
// stream, then reconnect and catch up on what was missed.
void (async () => {
const alive = await jmapClient.ping().catch(() => false);
if (!mounted) return;
if (!alive && useNetworkStore.getState().online) {
const ok = await useAuthStore.getState().retrySession().catch(() => false);
if (!ok || !mounted) return;
}
if (!handle) await start();
else handle.reconnect();
refreshAfterResume();
})();
});
// Reconnect when the network comes back while foregrounded.
const unsubscribeNetwork = useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online && appActive) {
if (handle) handle.reconnect();
else void start();
}
});
return () => {
mounted = false;
cleanup?.();
subscription.remove();
unsubscribeNetwork();
handle?.close();
handle = null;
};
}, [client, isAuthenticated]);
}, [client, isAuthenticated, haveLiveSession, activeAccountId]);
// Foreground keep-alive: a `Core/echo` every 30 s tells us when the server
// is unreachable even though the device is online (NetInfo cannot), and
// drives the per-account connection dot.
React.useEffect(() => {
if (!isAuthenticated || !haveLiveSession || !activeAccountId) return;
let cancelled = false;
let failures = 0;
const timer = setInterval(() => {
if (AppState.currentState !== 'active') return;
if (!useNetworkStore.getState().online) return;
void jmapClient.ping().then((ok) => {
if (cancelled) return;
failures = ok ? 0 : failures + 1;
const account = useAccountStore.getState().getAccountById(activeAccountId);
if (!account) return;
// One missed echo can be a blip; two in a row is a lost connection.
const connected = failures < 2;
if (account.isConnected !== connected) {
useAccountStore.getState().updateAccount(activeAccountId, { isConnected: connected });
}
}).catch(() => undefined);
}, 30_000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [isAuthenticated, haveLiveSession, activeAccountId]);
// Skip the "Restoring session" flash for returning users: if we already
// have a persisted active account, render the main UI immediately with
@@ -404,6 +646,8 @@ export default function App() {
}}
/>
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
<Stack.Screen
name="AddAccount"
options={{
+230
View File
@@ -0,0 +1,230 @@
# Webmail parity checklist
Audit of **Bulwark Mobile** (`bulwarkmail/native`, v0.1.58 @ `82b97fa`) against
**Bulwark Webmail** (`bulwarkmail/webmail`, v1.9.2 @ `39dcc6b2`), taken on
2026-08-29. The goal is feature *and* bugfix parity: every user-facing webmail
behaviour, and every webmail changelog fix since the native app was started
(1.4.5 → 1.9.2), was checked against the native code.
The audit was done by reading code on both sides, not by running the app. Every
finding cites `file:line` on both sides so it can be re-verified quickly; a
sample of the 32 P1 findings was additionally spot-checked by hand.
## How to use this document
- The detailed findings live in [docs/parity/](docs/parity/), one file per area.
Tick the `- [ ]` boxes there as you fix things; this file is the index.
- Each finding has a **priority** and a **category**:
| Priority | Meaning |
|---|---|
| `P1` | data loss, wrong send/delete, security, crash, or a core flow that is blocked |
| `P2` | notable feature missing or visibly wrong |
| `P3` | polish, nice-to-have, minor divergence |
| Category | Meaning |
|---|---|
| `missing` | webmail has it, native does not |
| `partial` | native has a reduced version |
| `bug` | native has it, but it is wrong |
| `bugfix-parity` | a bug the webmail fixed (changelog / issue number cited) that is still live in native |
| `rn-only-bug` | a bug that only exists in native (found while reading) |
- `WEB` paths are relative to the webmail repo, `RN` paths to this repo.
Issue numbers `#NNN` refer to the **webmail** tracker unless prefixed
"native #NN".
- Each area file ends with **Verified at parity** (do not redo) and
**N/A on mobile** (deliberately out of scope) lists.
- A few findings appear in two areas because they sit on a boundary (e.g.
"forward drops attachments" is in both viewer and composer). Fix once, tick
both.
## Baseline health of this repo (2026-08-29)
- [x] **`npm test` is red on `main`** — `P2` — `rn-only-bug` *(fixed in b84d4d8)*
- `src/stores/__tests__/auth-store.test.ts` fails to load with
`SyntaxError: Unexpected token 'typeof'`. The chain
`auth-store → email-store → outbox-store → network-store` imports
`@react-native-community/netinfo`, which `src/test-setup.ts` never mocks
(it mocks `react-native`, `expo-secure-store`, `expo-web-browser`,
AsyncStorage only). Broken since `5d47f47` (2026-05-30) when the outbox
import was added; `email-store.test.ts` only passes because it mocks
`../outbox-store` itself.
- Fix hint: add `vi.mock('@react-native-community/netinfo', () => ({ default: { addEventListener: vi.fn(() => () => {}), fetch: vi.fn(async () => ({ isConnected: true, isInternetReachable: true })) } }))`
to `src/test-setup.ts`. Expected afterwards: 31/31 suites (432+ tests).
- [x] **CI runs neither `tsc` nor `vitest`** — `P2` — `missing` *(fixed in fb4796e)*
- `.github/workflows/` only has `release-android.yml` and `release-ios.yml`.
Add a `ci.yml` running `npm ci && npm run typecheck && npm test` on push/PR.
- `npx tsc --noEmit` is clean.
- Uncommitted work in the tree at audit time: ascending/descending mail sort
(native #5) in `src/stores/email-store.ts`, `src/stores/settings-store.ts`,
`src/screens/EmailListScreen.tsx`, `src/stores/__tests__/email-store.test.ts`.
Findings that touch list ordering assume that lands.
- [x] **README is stale** — `P3` — it says "Filters & rules, S/MIME, plugins, *(fixed in 9ae91a6)*
themes, file storage – UI stubs only". Filters, Files and file sharing are
real (see areas 07); S/MIME, plugins, themes and sidebar apps are still
stubs (area 08).
## Status after the 2026-08-29 fix pass
Worked down the same day, area by area, by one orchestrating session plus one
agent per area, each change committed on its own (`type: short description`).
Per-area counts of ticked items are in the area files; every tick carries the
commit hash. The remaining `- [ ]` items are either deferred with a note
("deferred: …") or need work outside this repo (webmail hand-off `flow=oauth`
for TOTP accounts, relay APNs/UnifiedPush transports, a server-side settings
store for native #1).
## Areas
| # | Area | File | Items | P1 | P2 | P3 |
|---|---|---|---|---|---|---|
| 01 | Authentication, login, session, multi-account | [docs/parity/01-auth-accounts.md](docs/parity/01-auth-accounts.md) | 30 | 4 | 8 | 18 |
| 02 | Mail list, folders, unified views, search, tags | [docs/parity/02-mail-list-folders.md](docs/parity/02-mail-list-folders.md) | 54 | 1 | 20 | 33 |
| 03 | Email viewer, thread view, rendering, attachments | [docs/parity/03-email-viewer.md](docs/parity/03-email-viewer.md) | 46 | 6 | 13 | 26 |
| 04 | Composer, drafts, sending, identities, templates, scheduled send | [docs/parity/04-composer-send.md](docs/parity/04-composer-send.md) | 51 | 5 | 14 | 32 |
| 05 | Calendar and tasks | [docs/parity/05-calendar.md](docs/parity/05-calendar.md) | 51 | 5 | 20 | 26 |
| 06 | Contacts and address books | [docs/parity/06-contacts.md](docs/parity/06-contacts.md) | 51 | 1 | 18 | 31 |
| 07 | Filters (Sieve), vacation responder, Files | [docs/parity/07-filters-vacation-files.md](docs/parity/07-filters-vacation-files.md) | 33 | 3 | 8 | 22 |
| 08 | Settings, sync, push, i18n, themes, updates, misc UI | [docs/parity/08-settings-push-i18n-ui.md](docs/parity/08-settings-push-i18n-ui.md) | 46 | 0 | 14 | 32 |
| 09 | JMAP client core, live sync, offline, security, S/MIME | [docs/parity/09-jmap-core-sync-security.md](docs/parity/09-jmap-core-sync-security.md) | 56 | 7 | 23 | 26 |
| | **Total** | | **418** | **32** | **138** | **246** |
Counts are of `- [ ]` items per file (a handful of "verified" notes carry a
priority tag too, so P-sums can differ by one or two from the item count).
## All P1 findings (fix these first)
### Data loss / wrong send
- [x] No draft autosave, no save-on-close, and the OS back gesture bypasses the discard guard — closing a compose loses everything. → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
- [x] Cannot open or edit an existing draft (Drafts folder opens the read-only viewer). → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
- [x] Message is filed into **Sent before `EmailSubmission/set`**; a failed send leaves a never-sent copy in Sent and a retry duplicates it. → [04](docs/parity/04-composer-send.md) *(fixed in ccbe67c)*
- [x] `In-Reply-To` / `References` are written with the **JMAP email id** instead of the RFC Message-ID (`messageId`/`references` are never even fetched) — every reply breaks threading in recipients' clients. → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
- [x] Reply addressing: `Reply-To` ignored, the user's own address kept on reply-all, wrong recipients on self-sent replies (#703, 1.9.0). → [03](docs/parity/03-email-viewer.md) *(fixed in 7f956d6)*
- [x] **Forward drops every attachment.** → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
- [x] Sieve: RN generator has no `attachment` field and emits `header :contains "undefined" ""` for webmail-authored attachment rules — **any save on the phone corrupts the shared server script**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
- [x] Sieve: multi-value conditions (`string[]`, webmail 1.7.3) make the whole script "opaque" on RN; "Reset to visual builder" then wipes all rules. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
- [x] `FilterRuleModal` is always mounted and never re-seeds: editing rule B shows and **saves rule A's fields under B's id**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
- [x] Calendar recurrence scope dialog is a stub: "This event" / "This and following" act as "All events" — deleting one occurrence **destroys the series**. → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
- [x] Editing an expanded occurrence rewrites the master's `start` to the occurrence date (series shifts, earlier occurrences vanish). → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
- [x] All-day events grow by one day on every create-via-toggle and every re-save. → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
- [x] Events are created without `timeZone` (floating time). → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
- [x] Invitations are never sent: participants use the retired `sendTo`, no organizer participant, no `organizerCalendarAddress` (#500, #731). → [05](docs/parity/05-calendar.md) *(fixed in 5dc4070)*
- [x] vCard import writes dates as raw strings instead of RFC 9553 PartialDate (#224) — Stalwart rejects the whole card, so any contact with a birthday is lost on import. → [06](docs/parity/06-contacts.md) *(fixed in e650cb2)*
### Core flow blocked / wrong content shown
- [x] TOTP / 2FA password login is impossible (402 is shown as a generic failure). → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
- [x] Webmail password handoff breaks for TOTP-protected accounts (cross-repo: webmail should hand off `flow=oauth` after a TOTP-upgraded login). → [01](docs/parity/01-auth-accounts.md) *(fixed in 0d94d76)*
- [x] A failed "add account" resets the singleton client before connecting, killing the live session until relaunch. → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
- [x] Account Security screen is dead against every Stalwart server: it checks `session.capabilities` but Stalwart advertises `urn:stalwart:jmap` only in `accountCapabilities` (verified on 0.16.19) — **native #47**. → [01](docs/parity/01-auth-accounts.md) *(fixed in 032732c)*
- [x] Thread view: only the newest message of a thread is reachable; older messages cannot be opened unless threading is disabled. → [03](docs/parity/03-email-viewer.md) *(fixed in d2ed27f)*
- [x] Pager shows another message's body (or nothing) when the opened id is not in the active folder page — unified inbox, group inboxes, contact activity. → [02](docs/parity/02-mail-list-folders.md), [03](docs/parity/03-email-viewer.md) *(fixed in c8be383)*
- [x] HTML-only messages are rendered — and quoted in replies — as raw HTML source (**native #46**). `<style>` stripping and an old dark-mode CSS revision are the likely cause of **native #49**. → [03](docs/parity/03-email-viewer.md) *(fixed in 06742ef)*
### JMAP client, live sync, security
- [x] No request deadline on any fetch — a stalled socket (iOS background suspension, dead pooled connection) hangs send/save forever (#702). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 0b1c240)*
- [x] Most `src/api/*.ts` functions index `methodResponses[0][1]` blindly and never check `error` / `notUpdated` / `notDestroyed` — a rejected mutation is reported as success and the outbox drops it. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in ccbe67c)*
- [x] Password change leaves the stored credential stale; the next launch evicts the account. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 032732c)*
- [x] No `AppState` handling: SSE is never paused in background nor recovered on foreground, and `react-native-sse` never reconnects after a status-0 network error — push is silently dead after the first blip. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
- [x] SSE `Authorization` header is captured once; after an OAuth refresh every reconnect sends the stale token (401 → re-poll every 5 s forever). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
- [x] Push effect is keyed on the singleton client, so the SSE stream stays bound to the previous account after `switchAccount`. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
- [x] Webmail password handoff sends the clear-text password in a custom-scheme redirect fragment that any app can register; OAuth `state` uses `Math.random`; `server_url`/`token_endpoint` in the callback are trusted as-is. → [09](docs/parity/09-jmap-core-sync-security.md), [01](docs/parity/01-auth-accounts.md) *(fixed in 2c0dbd1)*
### Repo health
- [x] `npm test` is red on `main` (see Baseline health above). *(fixed in b84d4d8)*
## Native issues mapped to findings
| Native issue | Where it is explained |
|---|---|
| #49 Unreadable text in dark-mode emails | [03](docs/parity/03-email-viewer.md) `<style>` stripping + stale dark-mode CSS |
| #48 / #44 UnifiedPush / non-FCM push | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
| #47 Account security not accessible | [01](docs/parity/01-auth-accounts.md) capability map bug (P1) |
| #46 email preview shows css | [03](docs/parity/03-email-viewer.md) HTML-only body detection (P1) |
| #45 error when re-registering to relay | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
| #34 Native contacts/calendar sync adapters | feature request, out of parity scope; noted in [06](docs/parity/06-contacts.md) |
| #5 Sort ascending/descending | in progress in the working tree; ordering presets in [02](docs/parity/02-mail-list-folders.md) |
| #3 TLS client auth | Android-only today; gaps in [01](docs/parity/01-auth-accounts.md) and [09](docs/parity/09-jmap-core-sync-security.md) |
| #1 Shared settings between webmail and native | [08](docs/parity/08-settings-push-i18n-ui.md) — webmail sync is server-side/cookie-bound; a JMAP-blob design is sketched there |
## Cross-cutting themes
These come up in nearly every area; fixing them centrally pays off more than
per-screen patches.
1. **Email headers are never fetched.** `EMAIL_FULL_PROPERTIES` lacks
`messageId`, `inReplyTo`, `references`, `headers`, `replyTo` usage. This
single gap causes the broken threading headers, missing Reply-To handling,
no SPF/DKIM/DMARC chips, no List-Unsubscribe, no read receipts, no details
panel. (03, 04)
2. **No thread model.** The list collapses threads but the viewer is a
single-message pager over the folder page. (02, 03)
3. **Hard-coded English.** RN has an i18n layer and 15 vendored locales, but
84 of 94 screen/component files never call `t()`; calendar, files,
vacation, scheduled, login and every settings pane except Language and
Filters are English-only. Catalogs are ~6 weeks behind webmail (640 missing
keys), and there is no interpolation, plurals or RTL. (all areas, 08)
4. **Single-account assumptions.** Contacts, calendars (subscriptions), filters,
vacation and Files are primary-account only; shared/group accounts work
for mail folders but not elsewhere. (01, 05, 06, 07)
5. **Webmail bugfixes that are one-line ports:** `$junk`/`$notjunk` flip on
spam (#850), search defaults to all folders (#788), folder substring dedup
(#771), permanent-delete confirmation, `$pinned` vs `$important`, FileNode
`modified` vs `updated` (#700), percent-decoding WebDAV names (#869),
RSCALE/SKIP=OMIT removal (#805), first-touch gate for lazy default
calendar/address book creation (#907), `supported-calendar-component-set`
pinning (#760), request timeouts (#702), relative session URL resolution.
6. **Settings that do nothing.** 16–17 persisted keys are never read outside
the settings UI (layout, sound, calendar notifications, sidebar apps,
S/MIME, plugins, theme id, files thumbnails, …). Either wire them or remove
the toggles. (08, table in that file)
7. **Deep links / intents.** `bulwarkmobile://` is registered but nothing
handles it; no `mailto:` or share-to-app intent filters. (04, 08)
8. **Push.** Subscribes to `Email`+`Mailbox` instead of `EmailDelivery`, no
`emailPush` spam filter, ignores relay-forwarded ids and guesses "newest
unread", never matches the payload to an account, no device list/revoke,
Android/FCM only. (08)
9. **JMAP client robustness.** `src/api/jmap-client.ts` is a thin transport
with none of the layers webmail added since 1.4.5: request deadlines,
`maxConcurrentRequests` back-off, transient retry, rate-limit pause,
`maxObjectsInSet` / `maxSizeUpload` chunking, keep-alive monitoring,
first-touch serialisation, and — most importantly — method-error checking.
A shared `requireMethodResult` / `assertSetResult` helper used by every
`src/api/*.ts` function fixes a whole class of silent failures. (09)
10. **Live sync lifecycle.** No `AppState` or connectivity hooks around the
EventSource; reconnect, token refresh, account switch and client-cert
users all leave the stream dead or stale. (09)
## Suggested order of work
1. Repo health: fix `test-setup.ts`, add CI. Half a day.
2. The P1 list above, roughly in the order given (composer/send correctness,
Sieve corruption, calendar write path, vCard dates, auth blockers, viewer,
JMAP client + SSE lifecycle).
3. Cross-cutting themes 1, 2 and 9 (fetch headers, thread view, method-error
checking + request deadline) — they unlock a large slice of the P2 items in
03/04/09 and make the outbox trustworthy.
4. P2 items per area, starting with 02 (spam keywords, search scope, unified
inbox actions/paging, tags in rows) and 08 (push correctness, i18n
catalogs + `t()` sweep).
5. P3 items opportunistically while touching the same files.
## Explicitly out of scope on mobile
Collected from the per-area N/A lists so nobody re-litigates them: keyboard
shortcuts, drag & drop, three-pane / Pro split-screen shell, browser
back/forward and permalinks in the address bar, PWA install / service worker,
print, guided tour, CSP nonces / server-side SSRF guards, admin dashboard and
setup wizard, plugin sandbox and marketplace (a minimal "sidebar apps in a
WebView" subset is described in 08), impersonation, TOTP re-auth dialog for
cookie sessions, server-side telemetry, OpenGraph cards. Where a webmail
feature is server-mediated (settings sync, iCal fetch proxy, TOTP token
exchange, push relay list) the area file says what the native equivalent
would be.
## Keeping this current
Re-run the audit after each webmail release: read the new changelog "Fixes"
bullets, decide `RN affected / not affected / N/A`, and append to the matching
area file. When a finding is fixed, tick it and add the native commit hash in
the same line so the next audit can skip it.
+7 -6
View File
@@ -1,9 +1,9 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
</div>
@@ -26,10 +26,10 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
## What's missing or rough
- iOS build is untested
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
- iOS builds, but push notifications and client certificates are Android-only so far
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases)
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
## Run locally
@@ -41,6 +41,7 @@ npx expo start
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-release.md).
## License
+1 -1
View File
@@ -1 +1 @@
0.1.26
0.1.62
+32 -1
View File
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
@@ -12,7 +13,7 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
@@ -21,6 +22,36 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmobile"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<action android:name="android.intent.action.SENDTO"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="mailto"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/*"/>
<data android:mimeType="image/*"/>
<data android:mimeType="application/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="video/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
<data android:mimeType="application/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="video/*"/>
</intent-filter>
</activity>
<service android:name=".BulwarkMessagingService" android:exported="false">
<intent-filter>
@@ -141,7 +141,9 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
connectTimeout = timeoutMs
readTimeout = timeoutMs
requestMethod = method
instanceFollowRedirects = true
// Redirects are resolved in JS (lib/client-cert.ts) so a POST
// stays a POST and credentials never follow a cross-host hop.
instanceFollowRedirects = false
if (bodyBytes != null) {
doOutput = true
setFixedLengthStreamingMode(bodyBytes.size)
@@ -65,6 +65,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val emailId = options.getString("emailId")
val threadId = options.getString("threadId")
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
val groupKey = options.takeIf { it.hasKey("groupKey") }?.getString("groupKey")
?: accountId?.let { "bulwark-mail:$it" }
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
?: accountId ?: "Bulwark Mail"
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
@@ -73,8 +78,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
?: makeLetterAvatar(initials, bgColorHex)
postNotification(
notificationId, title, body, largeIcon, bgColorHex,
emailId, threadId, subject,
emailId, threadId, subject, accountId, groupKey,
)
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
promise.resolve(null)
}
}
@@ -85,6 +91,12 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
promise.resolve(payload?.toMap())
}
@ReactMethod
fun getInitialShare(promise: Promise) {
val payload = ShareIntentStore.consume()
promise.resolve(payload?.toMap())
}
private fun postNotification(
notificationId: String,
title: String,
@@ -94,6 +106,8 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
emailId: String?,
threadId: String?,
subject: String?,
accountId: String?,
groupKey: String?,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
@@ -101,6 +115,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
}
val pending = PendingIntent.getActivity(
ctx,
@@ -119,11 +134,64 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pending)
if (groupKey != null) {
builder.setGroup(groupKey)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, notificationId.hashCode(), builder.build())
}
// One summary per account group so several deliveries collapse into a
// single "N new messages" entry (Android renders the "+N more" itself
// from the children). Tapping the summary opens the app on the inbox;
// the per-message children carry the deep link.
private fun postGroupSummary(groupKey: String, groupTitle: String, colorHex: String, accountId: String?) {
val ctx = reactApplicationContext
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val children = manager.activeNotifications.filter {
it.groupKey?.endsWith(groupKey) == true &&
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
}
val count = maxOf(children.size, 1)
val inbox = NotificationCompat.InboxStyle()
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
val extras = sbn.notification.extras
val line = listOfNotNull(
extras.getCharSequence(android.app.Notification.EXTRA_TITLE),
extras.getCharSequence(android.app.Notification.EXTRA_TEXT),
).joinToString(" ")
if (line.isNotBlank()) inbox.addLine(line)
}
inbox.setBigContentTitle(groupTitle)
inbox.setSummaryText(if (count == 1) "1 new message" else "$count new messages")
val intent = Intent(ctx, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
}
val pending = PendingIntent.getActivity(
ctx,
groupKey.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_email)
.setContentTitle(groupTitle)
.setContentText(if (count == 1) "1 new message" else "$count new messages")
.setStyle(inbox)
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
.setGroup(groupKey)
.setGroupSummary(true)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setAutoCancel(true)
.setContentIntent(pending)
.build()
manager.notify(groupKey, groupKey.hashCode(), summary)
}
private fun makeLetterAvatar(initials: String, bgHex: String): Bitmap {
val size = 192
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
@@ -19,11 +19,15 @@ class MainActivity : ReactActivity() {
setTheme(R.style.AppTheme);
super.onCreate(null)
NotificationTapStore.captureFromIntent(intent)
ShareIntentStore.captureFromIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
ShareIntentStore.captureFromIntent(intent)?.let { share ->
BulwarkFcmModule.emit("app:share", share.toMap())
}
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
BulwarkFcmModule.emit("fcm:notificationTap", payload.toMap())
}
@@ -23,6 +23,11 @@ object NotificationTapStore {
// our notification builder writes — preventing other apps from
// navigating us to a forged email/thread.
private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$")
// accountId is `username@host` (see generateAccountId), so it needs `.`,
// `@`, `+` etc. — cap length and restrict charset to what that helper can
// emit. accountId is optional: notifications created before this field
// existed still navigate (using the active account as a fallback).
private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$")
fun captureFromIntent(intent: Intent?): TapPayload? {
val extras = intent?.extras ?: return null
@@ -34,12 +39,14 @@ object NotificationTapStore {
// cap its length so a hostile launcher can't ship a 1MB string into
// the navigation payload.
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
val payload = TapPayload(emailId, threadId, subject)
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
val payload = TapPayload(emailId, threadId, subject, accountId)
pending = payload
// Clear so a subsequent activity lifecycle event doesn't replay this.
extras.remove(EXTRA_EMAIL_ID)
extras.remove(EXTRA_THREAD_ID)
extras.remove(EXTRA_SUBJECT)
extras.remove(EXTRA_ACCOUNT_ID)
return payload
}
@@ -47,15 +54,18 @@ object NotificationTapStore {
val emailId: String,
val threadId: String,
val subject: String?,
val accountId: String?,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
putString("emailId", emailId)
putString("threadId", threadId)
if (subject != null) putString("subject", subject)
if (accountId != null) putString("accountId", accountId)
}
}
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
const val EXTRA_SUBJECT = "bulwark.notification.subject"
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
}
@@ -0,0 +1,85 @@
package com.anonymous.bulwarkmobile
import android.content.Intent
import android.net.Uri
import android.os.Build
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
// Process-wide holder for the most recent ACTION_SEND / ACTION_SEND_MULTIPLE
// intent (the system share sheet). MainActivity fills this on onCreate /
// onNewIntent; JS drains it via BulwarkFcm.getInitialShare or reacts to the
// live "app:share" event and opens the composer.
object ShareIntentStore {
@Volatile
private var pending: SharePayload? = null
fun consume(): SharePayload? {
val value = pending
pending = null
return value
}
fun captureFromIntent(intent: Intent?): SharePayload? {
val action = intent?.action ?: return null
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.take(MAX_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.take(512)
val uris = mutableListOf<Uri>()
if (action == Intent.ACTION_SEND) {
streamExtra(intent)?.let { uris.add(it) }
} else {
streamExtras(intent)?.let { uris.addAll(it) }
}
val type = intent.type
if (text.isNullOrBlank() && uris.isEmpty()) return null
val payload = SharePayload(
text = text,
subject = subject,
uris = uris.map { it.toString() },
mimeTypes = uris.map { type ?: "" },
)
pending = payload
// Clear so a later lifecycle event doesn't replay this share.
intent.removeExtra(Intent.EXTRA_TEXT)
intent.removeExtra(Intent.EXTRA_SUBJECT)
intent.removeExtra(Intent.EXTRA_STREAM)
intent.action = Intent.ACTION_MAIN
return payload
}
@Suppress("DEPRECATION")
private fun streamExtra(intent: Intent): Uri? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
@Suppress("DEPRECATION")
private fun streamExtras(intent: Intent): List<Uri>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
data class SharePayload(
val text: String?,
val subject: String?,
val uris: List<String>,
val mimeTypes: List<String>,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
if (text != null) putString("text", text)
if (subject != null) putString("subject", subject)
putArray("uris", Arguments.fromList(uris))
putArray("mimeTypes", Arguments.fromList(mimeTypes))
}
}
// 1 MiB of shared text is plenty; anything bigger is an attachment.
private const val MAX_TEXT = 1 * 1024 * 1024
}
+52 -3
View File
@@ -16,22 +16,42 @@ if (!COMMIT) {
}
COMMIT = COMMIT.slice(0, 7);
// App Store Connect rejects a build whose CFBundleVersion it has already seen
// for this CFBundleShortVersionString, so this has to advance on every upload
// even when VERSION does not. CI passes the workflow run number; local builds
// fall back to 1 (never uploaded).
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
module.exports = {
expo: {
name: 'Bulwark Mobile',
slug: 'bulwark-mobile',
// mailto: lets Android/iOS offer the app for mail links in other apps.
scheme: ['bulwarkmobile', 'mailto'],
version: VERSION,
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'dark',
userInterfaceStyle: 'automatic',
newArchEnabled: true,
splash: {
image: './assets/splash-icon.png',
resizeMode: 'contain',
backgroundColor: '#09090b',
backgroundColor: '#ffffff',
dark: {
image: './assets/splash-icon.png',
backgroundColor: '#09090b',
},
},
ios: {
supportsTablet: true,
bundleIdentifier: 'org.bulwarkmail.mobile',
buildNumber: IOS_BUILD_NUMBER,
config: {
// The app only speaks HTTPS/TLS and uses platform crypto, which is
// exempt. Declaring it here skips the manual export-compliance
// questionnaire that otherwise blocks every TestFlight build.
usesNonExemptEncryption: false,
},
},
android: {
adaptiveIcon: {
@@ -41,11 +61,40 @@ module.exports = {
edgeToEdgeEnabled: true,
predictiveBackGestureEnabled: false,
package: 'com.anonymous.bulwarkmobile',
// The AsyncStorage database holds cached message bodies, the outbox and
// the account registry; the platform backup would ship all of it to the
// user's Google account. Credentials live in SecureStore (excluded by
// its own rules) but the mail cache must not leave the device either.
allowBackup: false,
},
web: {
favicon: './assets/favicon.png',
},
plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'],
plugins: [
'expo-secure-store',
'@react-native-community/datetimepicker',
'expo-localization',
[
'expo-camera',
{
cameraPermission:
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
// QR scanning never records audio; leaving the mic entry in would be
// an unexplained permission in App Review.
microphonePermission: false,
},
],
[
'expo-image-picker',
{
photosPermission:
'Bulwark Mail needs access to your photos so you can attach them to emails and set contact photos.',
// Only launchImageLibraryAsync is used - no in-app capture.
cameraPermission: false,
microphonePermission: false,
},
],
],
extra: {
commit: COMMIT,
},
+159
View File
@@ -0,0 +1,159 @@
# iOS builds and TestFlight
The iOS app is built by [.github/workflows/release-ios.yml](../.github/workflows/release-ios.yml)
on a GitHub-hosted macOS runner and uploaded straight to TestFlight. macOS runner
minutes are free for this repository because it is public.
Unlike `android/`, which is committed and hand-maintained, **`ios/` is gitignored
and regenerated by `expo prebuild` on every run.** Never commit it, and never fix
anything by editing it - native iOS changes belong in `app.config.js` or a config
plugin, or they will be silently thrown away on the next build.
Without the secrets below the workflow still runs, but only as an unsigned
compile check; no IPA is produced.
## One-time Apple setup
You need an active Apple Developer Program membership ($99/yr). Everything here
can be done from Windows - no Mac required.
### 1. Team ID
developer.apple.com → Account → Membership details. A 10-character string like
`A1B2C3D4E5`.
### 2. Distribution certificate
Apple issues certificates against a certificate signing request. Keychain Access
is the usual way to make one, but OpenSSL works and does not need a Mac:
```bash
openssl genrsa -out dist.key 2048
openssl req -new -key dist.key -out dist.csr \
-subj "/emailAddress=you@bulwarkmail.org/CN=Bulwark Mail/C=DE"
```
Upload `dist.csr` at developer.apple.com → Certificates → **+** → **Apple
Distribution**, download the resulting `distribution.cer`, then bundle the cert
and its private key into a `.p12`:
```bash
openssl x509 -inform DER -in distribution.cer -out dist.pem
openssl pkcs12 -export -inkey dist.key -in dist.pem -out dist.p12 \
-passout pass:CHOOSE_A_PASSWORD
```
**On OpenSSL 3.x, add `-legacy` to that last command.** OpenSSL 3 defaults to
AES-256-CBC, which macOS `security import` refuses to read, and the workflow then
fails at the import step. OpenSSL 1.x has no `-legacy` flag and already writes
the format macOS wants - passing it there is an error. Check with `openssl
version` first; Git Bash on Windows currently ships 1.1.1.
Keep `dist.key` somewhere safe - you cannot re-download it, and losing it means
revoking the certificate and starting over.
### 3. App Store Connect API key
App Store Connect → Users and Access → Integrations → App Store Connect API →
**+**, with the **App Manager** role. Download the `.p8` (**one download only**)
and note the **Key ID** and the **Issuer ID** shown above the key list.
The key authenticates the TestFlight upload.
### 4. App ID, then app record
These live on two different Apple sites and have to be done in this order.
**developer.apple.com** → Certificates, Identifiers & Profiles → Identifiers →
**+** → App IDs → App. Explicit bundle ID `org.bulwarkmail.mobile`, matching
`ios.bundleIdentifier` in [app.config.js](../app.config.js). Leave every
capability off: push is not implemented on iOS, and sign-in uses the custom
`bulwarkmobile://` scheme rather than universal links, so no Associated Domains.
**appstoreconnect.apple.com** → Apps → **+** → New App. Platform iOS, any SKU.
The bundle ID here is a dropdown populated from the identifiers above, which is
why the App ID has to be registered first. The app name must be unique across
the entire App Store.
The TestFlight upload is rejected if the app record does not exist - and that
failure lands at the very end of a ~30 minute build.
### 5. App Store provisioning profile
developer.apple.com → Profiles → **+** → Distribution → **App Store Connect**.
Pick the App ID from step 4 and the distribution certificate from step 2, name it
`Bulwark Mobile App Store`, and download the `.mobileprovision`.
The build signs **manually** and this profile is not optional. Automatic signing
would be less to maintain, but `xcodebuild archive` under automatic signing always
signs with a *development* identity and leans on `-exportArchive` to re-sign for
distribution afterwards. That needs a development certificate and at least one
registered device - neither of which a CI-only TestFlight pipeline has any reason
to own, and Apple will not issue a development profile without a device. Naming
the App Store profile up front avoids the whole problem.
The profile expires when the certificate does (2027-08-01). Regenerate it here and
update the secret when that happens.
## Repository secrets
Settings → Secrets and variables → Actions:
| Secret | Value |
| --- | --- |
| `APPLE_TEAM_ID` | The 10-character team ID from step 1 |
| `IOS_DIST_CERT_BASE64` | `dist.p12` from step 2, base64-encoded |
| `IOS_DIST_CERT_PASSWORD` | The password you chose for `dist.p12` |
| `APP_STORE_CONNECT_KEY_ID` | Key ID from step 3 |
| `APP_STORE_CONNECT_ISSUER_ID` | Issuer ID from step 3 |
| `APP_STORE_CONNECT_PRIVATE_KEY` | The `.p8` from step 3, base64-encoded |
| `IOS_PROVISIONING_PROFILE_BASE64` | The `.mobileprovision` from step 5, base64-encoded |
To base64-encode a file:
```powershell
# PowerShell
[Convert]::ToBase64String([IO.File]::ReadAllBytes('dist.p12')) | Set-Clipboard
```
```bash
# bash
base64 -w0 dist.p12
```
## Running a build
Publishing a GitHub release triggers the iOS workflow alongside the Android one.
You can also run it by hand from the Actions tab, with an
`upload_to_testflight` toggle if you only want the IPA as a workflow artifact.
The IPA is deliberately not attached to the GitHub release: unlike the APK, it is
not installable outside TestFlight.
`CFBundleVersion` comes from the workflow run number via the `IOS_BUILD_NUMBER`
env var. App Store Connect rejects a build number it has already seen for a given
version, so never re-run a workflow expecting a re-upload to succeed - the run
number advances on its own, so just start a new run.
## After the upload
Processing takes roughly 5-30 minutes before the build shows up in TestFlight.
Internal testers (up to 100, must be on your App Store Connect team) get it
immediately. External testers (up to 10,000) need a Beta App Review, which takes
about a day the first time and is usually waved through afterwards.
## What does not work on iOS yet
- **Push notifications.** The push path is a hand-written Android module talking
to FCM (`BulwarkFcmModule`, `BulwarkMessagingService`). iOS needs an APNs key
and an equivalent native module; until then `push-notifications.ts` short-
circuits and the app polls like a device with push disabled.
- **Client certificates.** `BulwarkClientCertModule` is Android-only.
`secureFetch` falls back to plain `fetch`, so cert-authenticated servers will
not connect.
- **In-app updates.** Hidden on purpose - see `supportsSideloadUpdates` in
[src/lib/platform-capabilities.ts](../src/lib/platform-capabilities.ts).
Downloading and installing a binary is forbidden by App Store Review guideline
2.5.2, so iOS updates come through TestFlight instead. The Updates settings
pane is removed from the list on iOS; the version and commit are still shown
under About & Data.
+185
View File
@@ -0,0 +1,185 @@
# Authentication, login, session & multi-account
## Summary
RN covers the happy paths (password login, webmail-mediated OAuth handoff, QR pairing, per-account keychain credentials, restore/switch/logout, Stalwart account-security API) and several WEB fixes are already mirrored (refresh coalescing, cached-token reuse, keep-session-on-network-error). The biggest gaps are: no TOTP/2FA password login at all (and the webmail handoff silently breaks for TOTP accounts), the Account Security screen is dead against every Stalwart server because it checks the wrong capability map (native issue #47), a failed "add account" or failed switch destroys the live session, expired/revoked tokens never lead back to the login screen, account display names are never refreshed (#900), relative session URLs are not resolved, and there is no per-account remove / per-device push revoke / public-key management.
## Findings
### Login flow
- [x] **TOTP / 2FA password login is impossible** — `P1` — `missing` — fixed in b52768e
- What WEB does: login form has a TOTP field (auto-shown when the server answers 402 "MFA code required", `lib/jmap/client.ts:956-966` → `TOTP_REQUIRED`). With a code it calls `/api/auth/totp-token-exchange` which performs Stalwart's structured login `POST {server}/api/auth {type:'authCode', accountName, accountSecret, mfaToken, clientId, redirectUri, codeChallenge, codeChallengeMethod:'S256'}` then `POST {server}/auth/token` (authorization_code + PKCE) and continues as a bearer session (`app/api/auth/totp-token-exchange/route.ts:26-28, 90-93, 125, 150`; `stores/auth-store.ts:651-706`; changelog 1.7.5 "Support MFA login via the structured auth endpoint"). Legacy `password$totp` basic-auth fallback for pre-0.16 servers (`auth-store.ts:696-702`).
- What RN does: `JMAPClient.connect` only does HTTP Basic with the raw password (`src/api/jmap-client.ts:88-106`, `363-383`). A 402 becomes `"Session discovery failed: 402 …"`, which `describeLoginError` only maps for 403/404 (`src/lib/login-errors.ts:57`) so the user sees a generic "Sign-in failed". The 401 copy tells 2FA users to create an app password (`login-errors.ts:39-44`) — that is the only workaround today.
- Fix hint: detect 402 with a title containing `totp`/`mfa` in `fetchSession`, show a code step, then implement the two-step exchange directly on device (no CORS on native): `/api/auth` with `mfaToken` + `clientId: 'bulwark-webmail'` + PKCE, then `/auth/token`; persist the result as an OAuth bundle (`tokenEndpoint = ${server}/auth/token`, `clientId`) so `connectWithOAuth` and the existing refresh path take over.
- [x] **Webmail password handoff breaks for TOTP-protected accounts** — `P1` — `bug` (cross-repo) — fixed in 0d94d76 (RN retries the handed-off password with a code prompt; the webmail-side flow=oauth hand-off is still open)
- What WEB does: in mobile-handoff mode the login page always hands back `flow=password` with the raw password the user typed (`app/(main)/[locale]/login/page.tsx:638-642, 282`) even when the webmail itself upgraded that login to token auth via TOTP (`stores/auth-store.ts:651-706`, `upgradedToOAuth`).
- What RN does: `loginViaWebmail` feeds the password into `login()` → Basic auth (`src/stores/auth-store.ts:226-229`) → Stalwart 402 → "Sign-in failed". The user completed 2FA in the browser and still cannot sign in.
- Fix hint: WEB should hand off `flow=oauth` (access/refresh token, `token_endpoint`, `client_id`) when the password login was TOTP-upgraded (the mobile SSO branch in `app/api/auth/sso/complete/route.ts:66-97` already returns that shape); RN needs no change once WEB does that. Alternatively RN implements the previous finding and retries with a TOTP prompt when the handed-off password is rejected with 402.
- [x] **No direct OAuth/OIDC (PKCE) against the mail server — a Bulwark webmail at the JMAP origin is assumed** — `P2` — `missing` — fixed in 4bebf70
- What WEB does: discovers `/.well-known/oauth-authorization-server` or `openid-configuration` (`lib/oauth/discovery.ts:150-175`), runs client-side PKCE (`login/page.tsx:569-621`, `lib/oauth/pkce.ts`) or server-side SSO; works against Stalwart's built-in OAuth (default client id `bulwark-webmail`, `lib/oauth/token-exchange.ts:14`), Keycloak, Authentik.
- What RN does: `ConfirmStep → runHandoff(serverUrl)` opens `${serverUrl}/login?mobile_redirect_uri=…` (`src/lib/oauth.ts:49-56`, `src/screens/LoginScreen.tsx:128-135`). `serverUrl` comes from probing `/.well-known/jmap` on `domain`, `mail.domain`, `webmail.domain` (`src/lib/server-discovery.ts:70-72, 143-170`) — i.e. it finds the Stalwart host, and then assumes a Bulwark webmail lives at that same origin. When webmail is on another host (or the server only runs Stalwart), the browser opens a 404/Stalwart admin page and the only way in is the password step.
- Fix hint: either (a) do PKCE natively with `expo-web-browser`/`expo-auth-session` against the discovered `authorization_endpoint` (redirect `bulwarkmobile://auth/callback`, client id `bulwark-webmail`, scopes `openid email profile`), or (b) probe `${serverUrl}/api/config` before opening the handoff and ask for the webmail address when it is not a Bulwark instance.
- [x] **Login error classification is thinner than WEB** — `P3` — `partial` — fixed in b52768e
- What WEB does: `classifyLoginError` maps invalid credentials, network, 5xx `server_error`, `totp_required`; 429 is a `RateLimitError` with retry-after (`stores/auth-store.ts:71-96`, `lib/jmap/client.ts:849-853`).
- What RN does: `describeLoginError` (`src/lib/login-errors.ts`) has no 402 (MFA), 429 or 5xx branches; `RateLimitError('Rate limited by server')` falls to the generic "Sign-in failed" with the raw message.
- Fix hint: add branches for `402` → 2FA copy, `429`/`RateLimitError` → "try again in N s", `5xx` → "server error, try later".
- [x] **Saved-username suggestions on the login form** — `P3` — `missing` — fixed in 2404717
- What WEB does: remembers the last 5 usernames and offers them as an autocomplete (`login/page.tsx:500-517`).
- What RN does: `EmailStep`/`PasswordStep` start empty; in add mode `ChooseStep` only offers the known server, not known usernames.
- Fix hint: prefill/offer `accounts[].username` from the registry (and remember the last typed address in AsyncStorage) in `EmailStep`.
- [x] **Login flow strings are hard-coded English** — `P3` — `partial` — fixed in 2404717
- What WEB does: every login string is localized (`login.*` keys used throughout `login/page.tsx`).
- What RN does: `LoginScreen.tsx`, all `src/screens/login/*.tsx`, `QrScanModal.tsx` and `login-errors.ts` use literal English while `SettingsScreen` already uses `useLocaleStore().t` (`src/screens/SettingsScreen.tsx:169-190`).
- Fix hint: route through `t()` with `login.*` keys (the i18n audit may cover this; listed here so it is not lost).
- [x] **QR hint copy points to a settings page that does not exist** — `P3` — `bug` — fixed in b52768e
- What WEB does: the pairing QR is generated in Settings → Security ("Link device", `components/settings/account-security-settings.tsx:894-1026`).
- What RN does: hints say "Settings → Devices → Add phone" (`src/components/QrScanModal.tsx:66`) and "Settings → Devices" (`src/screens/LoginScreen.tsx:221`).
- Fix hint: change the copy to Settings → Security → Link device.
- [x] **Client TLS certificate support is Android-only and does not cover push/SSE** — `P3` — `partial` (native issue #3) — fixed in edc26ce (live updates fall back to polling with a cert; redirects handled in JS (6f747db); iOS implementation still deferred)
- What WEB does: N/A (the browser negotiates mTLS).
- What RN does: `src/lib/client-cert.ts` bridges an Android `BulwarkClientCert` module and `secureFetch` is used by the JMAP client, blob and discovery code. Gaps: iOS has no implementation (`getNative` returns null off Android, `client-cert.ts:34-45`); the JMAP SSE stream uses `EventSource` with a plain header (`src/api/push.ts:120-137`) so live updates fail behind mTLS; the system browser used for the webmail handoff cannot present the cert; the abort signal does not reach the native path (`server-discovery.ts:103-135` works around it).
- Fix hint: document the limits in the native issue; route SSE through a native fetch-based reader or the `react-native-sse` fetch option when a cert alias is set; consider an iOS `URLSession` delegate implementation.
### Session
- [x] **Relative session URLs are not resolved** — `P2` — `bugfix-parity` (changelog 1.9.0 "Resolve relative session URLs without corrupting URI templates") — fixed in 0b1c240 (unified-inbox.ts copy is with the mail-list agent)
- What WEB does: `rewriteSessionUrl` prefixes the connected origin when `apiUrl`/`downloadUrl`/`uploadUrl`/`eventSourceUrl` have no scheme, and rewrites the origin of absolute ones without touching `{accountId}`/`{blobId}` templates (`lib/jmap/client.ts:1091-1110`).
- What RN does: `rewriteSessionUrls` only rewrites absolute URLs; `extractOrigin` returns null for `/jmap/` so the relative value is passed through unchanged and `secureFetch('/jmap/')` fails (`src/api/jmap-client.ts:320-340`). Same in the unified inbox `rewriteApiUrl` (`src/api/unified-inbox.ts:55-60`).
- Fix hint: when the URL has no `^https?://`, return `serverOrigin + (url.startsWith('/') ? url : '/' + url)`; keep the plain string splitting (the comment about the RN URL polyfill corrupting templates still applies).
- [x] **Redirected session fetch may lose the Authorization header** — `P3` — `partial` — fixed in 0b1c240
- What WEB does: `fetchSessionResponse` detects a redirected 200 with no accounts/username (Safari and some auth proxies drop `Authorization` on redirect) and refetches `response.url` with the header (`lib/jmap/client.ts:911-925`).
- What RN does: `fetchSession` trusts the redirected response (`src/api/jmap-client.ts:363-383`); Stalwart 0.16.19 answers `/.well-known/jmap` with a 307 to `/jmap/session` (verified against stw-test19), and iOS `NSURLSession` can strip the header on redirect, which ends in `resolveAccountId` throwing "No account found in JMAP session".
- Fix hint: mirror the WEB check (`response.redirected` and empty `accounts`/`username` → refetch `response.url` with the header).
- [x] **Transient token-endpoint failure evicts the account** — `P2` — `bugfix-parity` (changelog 1.7.6 "Keep the session when the auth server is briefly unreachable", 1.7.8 "End refresh loops on sign-out and back off failed retries") — fixed in 2c0dbd1
- What WEB does: only a definitive 400/401/403 from the token endpoint drops the refresh token (`app/api/auth/token/route.ts:143`); 5xx/network → 503 and the client keeps the account and retries with backoff (`stores/auth-store.ts:1209-1226`, `1792-1799`, `isTransientAuthError` at `:106`).
- What RN does: `refreshOAuthAccessToken` throws `HandoffError` for any non-OK status or network error (`src/lib/oauth.ts:239-241`); `forceRefreshToken` turns every failure into `false` (`src/api/jmap-client.ts:211-221`) so an expired access token + a 5xx/offline token endpoint becomes `AuthenticationError`, and `restoreSession`/`switchAccount`/`retrySession` then delete the keychain entry (`src/stores/auth-store.ts:397-405, 505-512, 569-586`).
- Fix hint: in `refreshOAuthAccessToken` throw a distinct transient error for network failures, 429 and 5xx; in `fetchSession`/`request` rethrow it as `NetworkError` instead of `AuthenticationError`; only 400/401/403 from the token endpoint are definitive.
- [x] **A revoked/expired refresh token never leads back to the login screen** — `P2` — `rn-only-bug` — fixed in b52768e
- What WEB does: a 401 from refresh marks the session expired, logs out and redirects to login with a "session expired" banner (`stores/auth-store.ts:1209-1214`, `markSessionExpired`, `login/page.tsx` `session_expired` banner).
- What RN does: `request()` throws `AuthenticationError('Session expired')` (`src/api/jmap-client.ts:433`) but nothing outside `auth-store`/`login-errors` handles that class (grep over `src/`); the email store just records the message. `retrySession` returns early because `get().session` is still the stale object (`src/stores/auth-store.ts:554`). The user is stuck on a dead session until a relaunch.
- Fix hint: give `JMAPClient` an `onAuthFailure` callback (set by auth-store) that runs the `AuthenticationError` branch of `retrySession` (clear creds for the active account, `isAuthenticated:false`, error "Session expired"); or clear `session` in the store on that error so `retrySession` can run.
- [x] **Logout does not revoke the OAuth refresh token** — `P3` — `partial` — fixed in 4bebf70 (pairing bundles are deliberately not revoked)
- What WEB does: `DELETE /api/auth/token` posts the refresh token to the IdP `revocation_endpoint` on logout/remove/logout-all (`app/api/auth/token/route.ts:181-254`).
- What RN does: `logout`/`logoutAll` only delete the SecureStore entry (`src/stores/auth-store.ts:286-357`, `src/api/jmap-client.ts:350-361`); the refresh token stays valid server-side.
- Fix hint: discover `revocation_endpoint` from `${serverUrl}/.well-known/oauth-authorization-server` and POST `token=<refresh>&token_type_hint=refresh_token&client_id=…` best-effort. Caveat: a QR-paired phone shares the desktop's refresh token (`app/api/auth/pair/create/route.ts:18-22`), so revoking it would also sign out the desktop — only revoke bundles that came from the browser handoff, or accept that behaviour deliberately.
- [x] **No request deadline on JMAP calls** — `P3` — `partial` (changelog 1.8.1 "Time out stalled JMAP requests so a send can't hang forever (#702)") — fixed in 0b1c240
- What WEB does: `timedFetch` aborts when no response headers arrive within 30 s (300 s for blob transfers) and does not retry timed-out non-idempotent requests (`lib/jmap/client.ts:786-816`).
- What RN does: `request()`/`fetchSession` use bare `secureFetch` with no timeout (`src/api/jmap-client.ts:363-383, 410-445`); only the native-cert path (30 s) and the discovery probe (2.5 s) have deadlines.
- Fix hint: add an `AbortController` timeout in `request()` (and blob helpers), surfacing a distinct timeout error; do not auto-retry sends.
- [x] **SSE stream is opened without a token freshness check** — `P3` — `partial` (verify with the push audit) — fixed in edc26ce
- What WEB does: SSE uses `authenticatedFetch`, so a 401 triggers the refresh path.
- What RN does: `connectEventSource` reads `jmapClient.authHeader` once (`src/api/push.ts:137`) without `ensureFreshToken` (private, `src/api/jmap-client.ts:195`), so a nearly-expired access token can be used for a long-lived stream.
- Fix hint: expose an `ensureFreshToken()`-then-header helper on the client and call it before connecting/reconnecting the stream.
### Multi-account
- [x] **A failed "add account" destroys the active session** — `P1` — `rn-only-bug` — fixed in b52768e
- What WEB does: connects the new account with a fresh `JMAPClient` and only snapshots/clears the previous account after `connect()` succeeded (`stores/auth-store.ts:709-716`, `957-962`).
- What RN does: `login()` and `completeOAuthHandoff` call `jmapClient.reset()` and reset contacts/calendar *before* connecting (`src/stores/auth-store.ts:110-114, 175-180`). On failure the store still says the previous account is active, but the singleton now holds the wrong credentials and no session; cancelling the modal (`App.tsx:470-481`) restores nothing, so every request throws "Not connected" until relaunch.
- Fix hint: connect with a throwaway `new JMAPClient()` (or keep the current creds/session in locals) and only adopt the new connection into the singleton after success; on failure `await jmapClient.loadAccount(previousActive)`.
- [x] **A failed account switch leaves the client half-switched** — `P2` — `rn-only-bug` — fixed in b52768e
- What WEB does: on restore failure the previous account's client is re-activated (`stores/auth-store.ts:1546-1565`); rate-limited/transient failures keep both accounts.
- What RN does: `loadAccount(target)` overwrites `credentials` and nulls `session`/`_accountId` on failure (`src/api/jmap-client.ts:244-273`); `switchAccount` restores only the email-store view (`src/stores/auth-store.ts:393-411`) and leaves the store's `session` set, so `retrySession` short-circuits (`:554`) and the previous account is dead.
- Fix hint: in the catch, `await jmapClient.loadAccount(previousActive)` (or restore the saved in-memory creds/session) before returning; alternatively set `session: null` so the network-recovery watcher re-establishes it.
- [x] **Account display name is never refreshed from the server (#900)** — `P2` — `missing` (changelog 1.9.0 "Refresh the account display name from the Stalwart principal on login, restore, and switch") — fixed in b52768e
- What WEB does: seeds `displayName`/`email` from the primary identity at login, then `syncAccountDisplayName` reads `x:Account/get` (principal "Full name") on login, restore and switch and updates the registry (`stores/auth-store.ts:146-176, 741-751`; `lib/stalwart/principal.ts`).
- What RN does: `addAccount({ displayName: username, email: username })` (`src/stores/auth-store.ts:123-131, 186-193`) and nothing ever updates it; the drawer header shows the raw username (`src/components/SidebarDrawer.tsx:297`). `fetchPrincipal` already exists (`src/api/account-security.ts:157-176`) but only feeds the security screen, and saving a new display name there does not touch the registry.
- Fix hint: after each successful connect run `Identity/get` (name/email) and, when `urn:stalwart:jmap` is in the account's `accountCapabilities`, `fetchPrincipal()`; `accountStore.updateAccount(id, { displayName, email })`; also update on `updateDisplayName` success.
- [x] **OAuth account id/email derived from the raw JMAP username** — `P3` — `partial` — fixed in b52768e (email refreshed from the primary identity; registry id unchanged)
- What WEB does: prefers the primary identity email over `Session.username` for OAuth/SSO because OIDC `preferred_username` may not be an address (`stores/auth-store.ts:946-951, 1096`).
- What RN does: `connectWithOAuth` uses `session.username || accessToken.slice(0, 8)` (`src/api/jmap-client.ts:135-136`), so the same mailbox logged in via password and via OAuth can produce two registry entries, and the drawer synthesizes `username@host`.
- Fix hint: read `Identity/get` after connect and use the primary identity email for `email` (and ideally for the registry id).
- [ ] **Cannot remove a non-active account** — `P2` — `missing` (changelog 1.7.8 "Remove a specific account from the switcher")
- What WEB does: per-row remove button in the switcher (`components/layout/account-switcher.tsx:138-143, 322-333`) backed by `removeAccount` which tears down the client, evicts caches and clears cookies (`stores/auth-store.ts:1382-1401`).
- What RN does: only "Sign out of <active>" and "Sign out all" (`src/components/SidebarDrawer.tsx:400-437`).
- Fix hint: add a per-row remove (long-press or trailing X) that runs `teardownPushNotificationsForAccount(id)`, `jmapClient.clearAccountCredentials(id)`, `useEmailStore.removeAccount(id)`, `accountStore.removeAccount(id)`.
- [ ] **Switcher shows stale connection state and no error indicator** — `P3` — `partial`
- What WEB does: shows `hasError` (alert icon) with `errorMessage` and a live `isConnected` dot per account (`account-switcher.tsx:296-303`).
- What RN does: renders only the `isConnected` dot (`SidebarDrawer.tsx:355-361`); `hasError`/`errorMessage` (set in `restoreSession`) are never displayed, and since only the active account is ever connected, other rows show whatever value they had last.
- Fix hint: render `hasError`; show the dot only for the active account (or treat inactive as "cached").
- [x] **Accounts list in Settings (reorder, set default, add) is missing** — fixed in 490355c — `P3` — `missing` (changelog 1.7.2 "List and reorder logged-in accounts from settings (#282)", 1.7.7 "Pin the default account on top and drag-to-reorder") — move up/down + set default + remove + add; account-store.reorderAccounts
- What WEB does: `AccountSettings` lists all accounts with move up/down, drag reorder, set-default, add (`components/settings/account-settings.tsx:182-220`); switcher pins the default first (`lib/account-utils.ts:117-142`).
- What RN does: `AccountSettings` shows only the active account's fields (`src/components/settings/AccountSettings.tsx`); the drawer lists accounts in registry order with set-default only (`SidebarDrawer.tsx:390-406`).
- Fix hint: add an accounts section with set-default/remove/reorder (`accountStore` needs a `reorderAccounts`).
- [x] **AccountSettings reports the wrong auth method and never shows storage** — fixed in 490355c — `P3` — `rn-only-bug` — jmapClient.usesBearerAuth + Quota/get (src/api/quota.ts)
- What WEB does: shows OAuth vs Basic from `authMode` and a quota bar (`account-settings.tsx:151-180`).
- What RN does: `authMode = props.authMode ?? 'basic'` and `quotaUsed = props.quotaUsed ?? 0` (`AccountSettings.tsx:46-47`) but `SettingsScreen` renders `<Component />` with no props (`SettingsScreen.tsx:242`), so OAuth accounts read "Basic" and the storage row never appears (RN has no `Quota/get` at all — flag for the mail/settings audit).
- Fix hint: derive from `jmapClient.usesBearerAuth` (or persist `authMode` on `AccountEntry`); fetch quota via `Quota/get` when `urn:ietf:params:jmap:quota` is advertised.
- [ ] **Hard account cap of 5** — `P3` — `partial`
- What WEB does: 5 on HTTP/1.1, lifted to 50 when HTTP/2 is observed, because each web account pins an SSE socket (`lib/account-utils.ts:75-107`).
- What RN does: `MAX_ACCOUNTS = 5` (`src/lib/account-utils.ts:1`) although only the active account holds a live connection.
- Fix hint: raise the constant (or drop the cap) once the unified inbox cost per account is acceptable.
- [ ] **Shared/group account settings scope is missing** — `P3` — `missing` (changelog 1.7.5 "Manage shared/group account settings from the Accounts page") — deferred: shared accounts are now listed read-only in AccountSettings (490355c); scoped filter/vacation stores need an accountId parameter first
- What WEB does: lists `client.getSharedAccounts()` (non-primary) on the Accounts page and enters a scoped settings mode (filters, vacation, calendars, contacts) via `managed-account-store` (`components/settings/account-settings.tsx:43-48, 112-118, 225-262`; `stores/managed-account-store.ts`).
- What RN does: `getSharedMailAccounts()` exists for mail/unified inbox (`src/api/jmap-client.ts:480-495`) but there is no shared-account listing or scoped settings.
- Fix hint: list non-personal `session.accounts` in `AccountSettings` and pass an `accountId` into the filter/vacation stores.
- [ ] **Unified-inbox shared-account filter disagrees with `getSharedMailAccounts`** — `P3` — `rn-only-bug`
- What WEB does: a non-personal account counts as mail-capable even when its `accountCapabilities` omit mail (`lib/jmap/client.ts:4421-4445`); RN's own `getSharedMailAccounts` mirrors that (`jmap-client.ts:486-493`).
- What RN does: the unified inbox requires `accountCapabilities == null || MAIL in accountCapabilities` (`src/api/unified-inbox.ts:208-210`), so a shared account that advertises other capabilities but not mail is silently skipped there while it appears in the sidebar.
- Fix hint: reuse the `!advertisesMail && info.isPersonal → skip` rule from `getSharedMailAccounts`.
### Account security settings
- [x] **Account Security screen is dead against every Stalwart server (native issue #47)** — `P1` — `rn-only-bug` — fixed in 032732c
- What WEB does: probes `client.hasAccountCapability('urn:stalwart:jmap')`, i.e. the *account-level* capability map (`stores/account-security-store.ts:276-290`, `lib/jmap/client.ts:4173-4177`, `lib/stalwart/principal.ts:33`).
- What RN does: `isStalwartSupported()` checks the *session-level* `session.capabilities` (`src/api/account-security.ts:51-54`). Verified against Stalwart 0.16.19 (stw-test19): `urn:stalwart:jmap` is present only under `accounts[<id>].accountCapabilities`, not in the top-level `capabilities`. So `AccountSecuritySettings` always renders "Account security management requires a Stalwart server" (`src/components/settings/AccountSecuritySettings.tsx:584, 622`). The same effect also shows that copy when the session is merely offline (`:583`).
- Fix hint: `const acc = session.accounts?.[jmapClient.accountId]; return !!acc?.accountCapabilities?.[STALWART_CAPABILITY] || STALWART_CAPABILITY in (session.capabilities ?? {})`; show an "offline" notice instead of the Stalwart notice when `currentSession` is null.
- [x] **Push subscriptions: no per-device list/revoke and no forced recreate (#841)** — fixed in f6f26f7 — `P2` — `missing` (changelog 1.9.0 "Per-device revoke for push subscriptions (#841)", "Recreate the push subscription on re-register (#841)")
- What WEB does: lists relay devices with a "this device" marker and per-device revoke, and Re-register passes `forceRecreate` so a stale subscription is recreated rather than its expiry refreshed (`components/settings/notification-settings.tsx:95, 115-131, 169`).
- What RN does: Enable / Re-register / Disable for this device only (`src/components/settings/NotificationSettings.tsx:81, 186-198`); no device list, and whether Re-register recreates the JMAP `PushSubscription` was not verified here (push audit).
- Fix hint: call the relay device-list endpoint the web uses, render rows with revoke (`PushSubscription/set destroy` + relay unregister), and thread a `forceRecreate` flag through `setupPushNotifications`.
- [x] **Public keys (S/MIME/PGP) and encryption-at-rest configuration are missing** — fixed in ddae1e6 — `P3` — `missing` (changelog 1.8.1 "Manage S/MIME and PGP public keys and configure Stalwart encryption at rest from account security settings")
- What WEB does: `PublicKeysSection` with `x:PublicKey/query|get|set` and `x:AccountSettings/set encryptionAtRest {@type, publicKey, encryptOnAppend, allowSpamTraining}` (`components/settings/account-security-settings.tsx:573-840`; `stores/account-security-store.ts:397-455, 625-700`).
- What RN does: read-only `EncryptionSection` showing the `@type` only (`AccountSecuritySettings.tsx:539-555`; `src/api/account-security.ts:150-155`).
- Fix hint: port the four store methods to `src/api/account-security.ts` and add a key list + encryption picker.
- [x] **OAuth `state` is generated with `Math.random`** — `P3` — `rn-only-bug` — fixed in 2c0dbd1
- What WEB does: `crypto.getRandomValues` for verifier/state (`lib/oauth/pkce.ts:11-27`).
- What RN does: `randomState()` uses `Math.random` (`src/lib/oauth.ts:41-46`) although a CSPRNG helper with `getRandomValues`/`randomUUID` fallbacks already exists in `src/lib/totp.ts:16-46`. The state is the only guard against a forged `bulwarkmobile://` redirect delivering foreign credentials.
- Fix hint: export `randomBytes` from `totp.ts` (or a shared `random.ts`) and use it here.
## Verified at parity (brief list, so the fixer knows NOT to redo)
- QR login payloads: WEB emits `bulwarkmail://pair?server=<webmailBase>&code=…` (`account-security-settings.tsx:971`); RN parses it plus a `connect` variant and bare URLs (`src/lib/oauth.ts:135-162`) and redeems at `/api/auth/pair/redeem` (`:165-207`) into the same OAuth bundle the browser handoff yields.
- Webmail handoff (`mobile_redirect_uri`/`mobile_state`, fragment transport, state check, password/oauth flows) matches `login/page.tsx:115-130, 282, 638-650` and `auth/callback/page.tsx` mobile branch.
- Token refresh: coalesced per refresh token (`src/lib/oauth.ts:210-262`, WEB `refreshPromises`); cached access token reused until 60 s before expiry (#552; `jmap-client.ts:195-209`); client id always carried in the bundle so #873 does not apply; rotated refresh tokens persisted.
- Session restore: waits for AsyncStorage hydration, restores active → default account, keeps credentials and shows cached data on `NetworkError` (mirrors 1.7.6 "keep the session when the auth server is briefly unreachable"), evicts on definitive `AuthenticationError` (mirrors 1.6.0 "evict unrecoverable basic-auth accounts"), legacy single-slot migration.
- Logout: revokes this account's push subscription first, clears only that account's caches, switches to default/next account or falls back to the login screen; "Sign out all" wipes everything.
- Switch: email store is swapped before the network round-trip so the cached list shows immediately (same intent as 1.7.8 "eliminate the full-screen flash when switching accounts").
- Shared/group discovery: `getSharedMailAccounts` applies the WEB rule (non-personal OR advertises mail); unified inbox namespaces by `sourceAccountId`/`jmapAccountId`.
- Account security API (password change, display name, TOTP enrol/disable with `notUpdated` surfaced — 1.7.5 "Surface server errors on password change and TOTP toggle", app passwords, API keys with expiry/allowed IPs, principal/encryption reads, `Promise.allSettled` for forbidden principal reads) mirrors `stores/account-security-store.ts`; password/TOTP/display-name sections hidden for bearer sessions like WEB `isOAuth`.
- Server discovery by email domain (`/.well-known/jmap` probe on bare/`mail.`/`webmail.` hosts, 401 counts as a hit, known servers trusted) is RN's equivalent of WEB's admin server list / auto-pick-by-domain (#799); neither side does `_jmap._tcp` SRV.
- Login error → session-expired banner: RN's `ChooseStep` shows the store error ("Session expired") like WEB's `session_expired` banner.
- `AuthenticationError` on 401, `RateLimitError` on 429 with Retry-After parsing in `request()`.
## N/A on mobile
- "Remember me" (RN always stores credentials in the device keychain), `rememberMeEnabled`/`SESSION_SECRET` cookie logic, per-slot cookie handling (`cookieSlot`, `oauth_cookie_slot`), orphan-cookie adoption, `serverIdentifiers`/`classifySessionMatch` slot→token desync guard (RN keys credentials per registry id, no shared slot).
- Server-configured login branding/notice (`LOGIN_SHOW_TOTP`, `LOGIN_SHOW_VERSION`, logos, imprint/privacy links, company name), OAuth-only mode / auto-SSO / iframe embedded SSO (#69), server picker dropdown (#799), custom JMAP endpoint toggle, demo mode, dev-mode login, CORS error classification.
- Impersonation (`/api/auth/impersonate`, `%master` suffix stripping, impersonation reconciler), admin login.
- TOTP re-auth dialog (`totp-reauth-store`/`totp-reauth-dialog`): only used by WEB's legacy `password$totp` basic-auth path for pre-0.16 Stalwart; once RN gains TOTP via the token exchange no re-auth is needed.
- "Link device" QR generator (RN is the device being linked); TOTP enrolment QR image (same-device — RN's `otpauth://` deep link + manual secret is the right shape).
- 30 s keep-alive ping / `connectionLost` state (RN uses NetInfo + `retrySession`), per-tab SSE budget (#702), settings-sync enable/disable on login (settings audit), Stalwart passthrough auth context (`/api/auth/stalwart-context`), plugin logout hooks.
+310
View File
@@ -0,0 +1,310 @@
# Mail list, mailboxes/folders, unified views, search, tags, batch actions
## Summary
RN covers the core single-folder loop well (folder tree incl. shared/group accounts with owner-routed mutations, incremental Email/queryChanges refresh, offline seed, swipe actions with reveal mode, multi-select with archive/move/delete/tag and an undo snackbar WEB does not even have). The biggest gaps are: (1) tags are invisible in the list and there is no tag view/tag counts; (2) spam/not-spam only moves the message and never flips `$junk`/`$notjunk` (#850), and there is no not-spam in the list at all; (3) search is always folder-scoped (WEB defaults to all folders, #788) with no history/suggestions; (4) the unified inbox is a one-shot, inbox-only, cross-account-only screen with no actions, paging, counts, Unread/Starred/All-mail views or per-role views; (5) folder management is create/rename/delete only (no subfolders, move, reorder, empty folder, mark folder read); (6) several WEB bugfixes are still live in RN: #771 substring dedup hides folders, permanent delete has no confirmation, Sent/Drafts show sender instead of recipient, "Never" mark-as-read marks read instantly, pins are written as `$important` instead of `$pinned`. One P1 RN-only bug: opening a message from the unified inbox that is not in the active folder page renders another message's body in the pager.
## Findings
### Folder tree / mailboxes
- [x] **Folders whose name contains a system folder name disappear (#771)** — `P2` — `bugfix-parity` — fixed in 516c9c9
- What WEB does: dedup only drops a root folder whose name is an *exact* (trimmed, case-insensitive) match of a role folder in the same account (`lib/utils.ts:270-333`, comment cites #771; changelog 1.9.0).
- What RN does: `deduplicate()` in `src/lib/mailbox-tree.ts:49-68` uses `lower.includes(rn) || rn.includes(lower)` — "Old Inbox", "2025 Archive", "Sent to Accounting" vanish from the drawer and the move sheet (also from `findTrashMailbox` etc. only indirectly).
- Fix hint: replace the substring test with `r.name.trim().toLowerCase() === lower`; keep the nested/parent guards. Add a case to `src/lib/__tests__/mailbox-tree.test.ts`.
- [x] **Role folders not localized (#404)** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: `localizeMailboxName(role, name, t)` maps inbox/sent/drafts/trash/archive/junk/important/flagged/all to translated labels (`lib/mailbox-label.ts:35`); used by sidebar (`components/layout/sidebar.tsx:520`), move menu, context menus.
- What RN does: raw server name everywhere: drawer `src/components/SidebarDrawer.tsx:491`, list header `src/screens/EmailListScreen.tsx:637-641`, `MoveSheet.tsx:120`, `FolderSettings.tsx:26-29` (English-only pill).
- Fix hint: add `src/lib/mailbox-label.ts` (port of WEB) using `useLocaleStore().t` keys `sidebar.mailboxes.*` (already present in `locales/*/common.json`), apply in the four places.
- [x] **Setting to hide the total message count on folders (#498)** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: `showFolderTotalCount` setting (`stores/settings-store.ts`, `components/settings/layout-settings.tsx:236`) hides the `/ total` part in `SidebarRowCounts` (`components/layout/sidebar.tsx:207-209`).
- What RN does: `RowCounts` always shows unread/total (`src/components/SidebarDrawer.tsx:68-79`).
- Fix hint: add `showFolderTotalCount` to settings-store + LayoutSettings toggle; gate `total` in `RowCounts`.
- [x] **Create subfolder / choose parent when creating** — `P3` — `partial` — fixed in 6ae21d5
- What WEB does: "New subfolder" in the folder context menu and `subfolder_of` in settings (`components/layout/mailbox-context-menu.tsx:187-193`, `components/settings/folder-settings.tsx:408-423`); store `createMailbox(client, name, parentId, accountId)` (`stores/email-store.ts:3753`), incl. creating in a shared account.
- What RN does: `FolderSettings.saveDraft` always calls `createMailbox({ name })` (`src/components/settings/FolderSettings.tsx:96`) although `createMailbox` in `src/api/email.ts:156` already accepts `parentId` and `accountIdOverride`.
- Fix hint: add a "Parent folder" select (own folders, or the shared account when creating there) to the editor modal and pass `parentId`.
- [ ] **Move folder to another parent (#855) and reorder folders (sortOrder)** — `P3` — `missing` — partial in 6ae21d5: "Move under" picker + parent re-fetch done; deferred: sortOrder reorder UI (no drag-and-drop on mobile yet)
- What WEB does: drag & drop in folder settings and sidebar reparents (`moveMailbox` `stores/email-store.ts:3898-3956`, refetches after reparent per #855) and reorders via `sortOrder` (`reorderMailboxes` `:3859`); folder-settings DnD plan `components/settings/folder-settings.tsx:241-262`.
- What RN does: no UI; `updateMailbox` in `src/api/email.ts:185` accepts `parentId` but not `sortOrder`. Tree sorts by `sortOrder` already (`src/lib/mailbox-tree.ts:70-81`).
- Fix hint: in the rename editor add a "Move under…" picker (own-account folders, excluding descendants) → `updateMailbox(id, { parentId })` then `fetchMailboxes()`; optionally up/down buttons writing `sortOrder`.
- [ ] **Assign/clear a folder role, custom folder icons, colorful-icon toggle** — `P3` — `missing` — partial in 6ae21d5: role picker + #288 role icons done; deferred: per-folder custom icons and the colorful-icon toggle (cosmetic)
- What WEB does: role dropdown per folder (`components/settings/folder-settings.tsx:366-380`, store `setMailboxRole` `stores/email-store.ts:3830`), per-folder icon picker (`folder-settings.tsx:77`, `folderIcons` setting), `colorfulSidebarIcons` toggle (`layout-settings.tsx:222`). Distinct icons for shared/important/memos/scheduled/snoozed roles (#288, `components/layout/sidebar.tsx:131-160`).
- What RN does: fixed role colours always on, `iconFor` knows only inbox/sent/drafts/trash/junk/archive/star (`src/components/SidebarDrawer.tsx:36-66`); no role assignment.
- Fix hint: low priority; at least extend `iconFor` with the #288 roles (important/memos/scheduled/snoozed/shared) and ideally a role picker in FolderSettings using `updateMailbox(id, { role })` (needs `role` added to the `changes` type).
- [x] **Hide the server "Scheduled" role folder when the virtual Scheduled row is shown (#495)** — `P3` — `bugfix-parity` — fixed in 6ae21d5
- What WEB does: filters `role === 'scheduled'` nodes out of the own tree while the virtual row is rendered (`components/layout/sidebar.tsx:986-990`).
- What RN does: drawer always renders the virtual "Scheduled" quick row (`src/components/SidebarDrawer.tsx:447-453`) and would also list a Stalwart `role: scheduled` mailbox as a plain folder.
- Fix hint: skip nodes with `role === 'scheduled'` in `buildMailboxTree`/drawer when `jmapClient.hasDelayedSend()`.
- [x] **Empty folder (Trash/Junk) — with #711 pagination** — `P2` — `missing` — fixed in 3194b5e
- What WEB does: "Empty folder" banner/button in Trash & Junk and in the folder context menu (`components/email/email-list.tsx:457-479`, `components/layout/mailbox-context-menu.tsx:213-219`); `client.emptyMailbox` loops `Email/query` + back-referenced `Email/set destroy` in batches of `min(500, maxObjectsInSet)` and never gates on `total` (`lib/jmap/client.ts:2288-2325`, changelog 1.8.1 #711); store zeroes counters (`stores/email-store.ts:3958`).
- What RN does: nothing — the only way is select-all-on-page + delete, page by page.
- Fix hint: add `emptyMailbox(mailboxId, accountId?)` to `src/api/email.ts` mirroring the WEB loop (stop when `found.length === 0 || destroyed === 0 || found.length < batch`), a confirm `Alert`, a button in the list header when `currentMailbox.role` is trash/junk, then `refreshEmails()` + `fetchMailboxes()` and `dropFromCache`.
- [x] **Mark folder / folder tree / all folders as read** — `P2` — `missing` — fixed in 6ae21d5
- What WEB does: folder context menu → `markMailboxAsRead` (paged `Email/query notKeyword $seen` + `Email/set`, `lib/jmap/client.ts:2327-2364`), "mark folder tree read" and "mark all folders read" with confirm (`components/mail/mail-app.tsx:2571-2645`, `client.markAllAsRead` `:2366`); store zeroes unread counters (`stores/email-store.ts:4003-4043`).
- What RN does: only per-message and batch-on-loaded-page mark read (`EmailListScreen.tsx:456-464`).
- Fix hint: long-press on a drawer folder row → action sheet (Mark all read / Empty / Rename / Delete / New subfolder); api `markMailboxAsRead(rawId, accountId)` with the paged loop; then `fetchMailboxes()` and, if current, `refreshEmails()`.
- [x] **Retry button after a failed first load does not retry the mailbox fetch** — `P2` — `rn-only-bug` — fixed in 3194b5e
- What WEB does: retries mailbox fetch on first login for lazy provisioning (#217) and keeps the tree on later failures (#780).
- What RN does: when `fetchMailboxes` fails with zero mailboxes it sets `error` (`src/stores/email-store.ts:610-612`); the list shows the error with a "Retry" that calls `refreshEmails()` (`src/screens/EmailListScreen.tsx:812`), which returns immediately because `currentMailboxId` is null (`email-store.ts:773`). The mount effect only re-runs when `mailboxes.length` changes, so the user is stuck until an app restart.
- Fix hint: Retry should call `fetchMailboxes()` when `mailboxes.length === 0` (then the inbox-select effect kicks in); consider one automatic retry after ~2 s on first login.
- [x] **No coalescing of concurrent refreshes (maxConcurrentRequests / 429)** — `P3` — `partial` — fixed in f07e039
- What WEB does: `coalesceRefresh` shares an in-flight `fetchMailboxes`/`refreshCurrentMailbox`/tag-count run and queues at most one re-run (`stores/email-store.ts:383-425`, #780).
- What RN does: every push event awaits `fetchMailboxes()` then `refreshEmails()` (`src/stores/email-store.ts:981-1015`); overlapping pushes, the mount effects (`EmailListScreen.tsx:554-567`) and `archiveEmail`'s follow-up `fetchMailboxes` can all run in parallel; a `RateLimitError` (`src/api/jmap-client.ts:436-440`) is only logged. The tree itself is preserved on failure (`email-store.ts:604-612`), so this is perf/noise rather than data loss.
- Fix hint: wrap `fetchMailboxes`/`refreshEmails` bodies in a small in-flight map keyed by `activeAccountId` with a single queued re-run, like WEB's `coalesceRefresh`.
- [x] **Tap the unread count of a folder to open it filtered to unread** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: unread badge is a button that toggles `isUnread: true` on that folder (`components/layout/sidebar.tsx:570`, `components/mail/mail-app.tsx:2533-2567`).
- What RN does: counts are static text (`SidebarDrawer.tsx:68-79`).
- Fix hint: make the unread count pressable → `selectMailbox(id)` then `setFilters({ isUnread: true })`.
- [x] **Shared-account header has no folder actions** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: right-click on a shared account header offers "New folder" in that account (`components/layout/sidebar.tsx:handleSharedAccountContextMenu`, `mailbox-context-menu.tsx:119-147`; changelog 1.9.0 "route shared-folder management to the owner account").
- What RN does: `FolderSettings` manages own folders only (`src/components/settings/FolderSettings.tsx:47-50`); the account node in the drawer only expands/collapses (`SidebarDrawer.tsx:499-503`).
- Fix hint: after the subfolder/move work, add an account picker (own + shared accounts the user `mayCreateChild` in) to the folder editor and pass `accountIdOverride`.
### Unified mailbox / cross-account views
- [x] **Unified scope: account-bounded by default, cross-account opt-in; single-account + group inboxes case** — `P2` — `partial` — fixed in 6ae21d5
- What WEB does: unified views stay inside the active account (own + shared/group folders) unless `unifiedCrossAccount` is on *and* the admin gate allows it (`components/mail/mail-app.tsx:465-500`, `stores/settings-store.ts:405-431`); the section is shown with one account as soon as `includeGroupInUnified && hasGroupInboxes` (`components/layout/sidebar.tsx:905-907`).
- What RN does: "All inboxes" is shown only when `accounts.length > 1` (`src/components/SidebarDrawer.tsx:438-446`) and always spans every account (`src/api/unified-inbox.ts:236-257`). A single account with group inboxes gets no unified view even with "Include Group Inboxes" on (`ReadingSettings.tsx:257`). Admin policy gates do not exist in RN (see N/A).
- Fix hint: show the row when `accounts.length > 1 || (includeGroupInUnified && mailboxes.some(m => m.isShared))`; add a `unifiedCrossAccount` toggle (default off, like WEB) and pass only the active account id when it is off. Note WEB's default for `includeGroupInUnified` is `true` (`settings-store.ts:639`) while RN defaults to `false` (`src/stores/settings-store.ts:235`).
- [x] **Unified per-role views (All Sent/Drafts/Junk/Archive/Trash) with live counts** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: one row per role present in any account with summed unread/total (`lib/unified-mailbox.ts:295-320`, sidebar rows `components/layout/sidebar.tsx` "unified_*"), counts projected from live mailbox lists.
- What RN does: inbox only (`src/api/unified-inbox.ts:149`), no counts on the "All inboxes" row.
- Fix hint: after the per-account mailbox fetch, pick `role` instead of hard-coding inbox; sum `unreadEmails` of each account's inbox for the drawer badge.
- [x] **All mail / Unread / Starred cross views + per-account folder selection** — `P2` — `missing` — fixed in 6ae21d5
- What WEB does: `buildCrossFilter` (OR of included mailboxes AND `notKeyword $seen` / `hasKeyword $flagged`) fanned out per account (`lib/unified-mailbox.ts:338-492`); default = inbox + custom folders (`CROSS_EXCLUDED_ROLES`), narrowed by `allMailFolderIds[accountId]` chosen in Layout settings (`components/settings/layout-settings.tsx:291-340`); rows show the source folder chip (`components/email/thread-list-item.tsx:385`, `resolveSourceFolderName` `lib/unified-mailbox.ts:63`).
- What RN does: none.
- Fix hint: reuse `fetchInboxEmailsForJmapAccount` with a filter built like `buildCrossFilter` over `Mailbox/get` results minus excluded roles; add a `sourceFolder` label to `UnifiedEmail` rows; folder picker can come later.
- [x] **Unified list has no pagination ("load more")** — `P2` — `missing` — fixed in 6ae21d5
- What WEB does: `loadMoreUnifiedEmails` / cross-view load-more with `position` per account (`stores/email-store.ts:4079-4107`, `1473-1515`).
- What RN does: fixed `perAccountLimit = 25`, no `position`, no `onEndReached` (`src/api/unified-inbox.ts:236-241`, `src/screens/UnifiedInboxScreen.tsx:175-182`).
- Fix hint: keep a per-account `position`, add `onEndReached` that re-queries each account at its own offset and merges.
- [x] **Unified search** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: text + advanced filter fan-out in all unified/cross views (`lib/unified-mailbox.ts:177-207`, `455-492`; changelog 1.7.8).
- What RN does: no search box on `UnifiedInboxScreen`.
- Fix hint: add the same search bar as `EmailListScreen` and AND `{ text: toWildcardQuery(q) }` into each account's query.
- [x] **No actions on unified rows (swipe, star, read, delete, archive, spam, selection)** — `P2` — `missing` — fixed in 6ae21d5
- What WEB does: every list action resolves the email's own client + owner account via `resolveEmailActionContext` (`stores/email-store.ts:581-640`), incl. batch actions grouped by `sourceAccountId` (`:2634-2660`, `:2740-2760`), archive into the owner's archive (`components/mail/mail-app.tsx:2141-2160`); changelog 1.7.5/1.7.7 "route counter/keyword updates to the email's own account in aggregate views".
- What RN does: rows are plain `Pressable`s (`src/screens/UnifiedInboxScreen.tsx:95-138`); nothing but open.
- Fix hint: wrap rows in `SwipeableRow`; because the JMAP client is single-account, actions on a *different registry account* need the detached `jmapPost` path (`unified-inbox.ts:116-131`) with `Email/set` against `email.jmapAccountId`; for the active account and its group accounts route through `setEmailKeywords(..., email.jmapAccountId)` / `moveEmail(...)` overrides.
- [x] **Unified list stays stale after acting on a message; just-read mail handling** — `P3` — `partial` — fixed in 6ae21d5
- What WEB does: aggregate views refresh through the fan-out on push (`refreshCurrentMailbox`, #791) and keep just-read/unstarred rows in the Unread/Starred views until re-opened (`retainedInViewIds`, `stores/email-store.ts:1007-1050`).
- What RN does: `UnifiedInboxScreen` loads once per mount/`includeGroup` change (`:57-59`); after opening (mark read) or deleting in the thread screen and going back, the unread dot/row is stale until pull-to-refresh. No push hookup.
- Fix hint: `useFocusEffect` → reload, or patch the local list from the thread screen result; when Unread/Starred views are added, keep the WEB retain semantics.
- [x] **Opening a unified-inbox message that is not in the active folder page shows another message's body** — `P1` — `rn-only-bug` — fixed in c8be383
- What WEB does: opens the clicked row object itself and fetches by source client/account (#847, `components/mail/mail-app.tsx:3111-3190`).
- What RN does: `UnifiedInboxScreen.onOpen` switches account then navigates to `EmailThread` (`src/screens/UnifiedInboxScreen.tsx:61-86`). `EmailThreadScreen` pages over the *active folder's* `emails` (`src/screens/EmailThreadScreen.tsx:131-133`, FlatList `data={emails}` `:538`, `initialScrollIndex={Math.max(0, findIndex)}`). A group-inbox message (or any message not in the first page of the user's own inbox snapshot) is not in `emails`, so index 0 is shown: the pane renders `emails[0]` while the toolbar/`activeEmailId` (and delete/archive/spam) refer to the message that was tapped; with an empty snapshot the body area is blank. Bare-id `findIndex` also collides across accounts (Stalwart reuses id ranges, #847).
- Fix hint: when `route.params.emailId` is not found in `emails` (or `jmapAccountId` is set), page over a one-element list `[{ id, threadId }]` instead of `emails`; compare ids together with the owning account.
- [x] **Unified fetch re-discovers the session and mailboxes on every refresh** — `P3` — `rn-only-bug` — fixed in 6ae21d5
- What RN does: `fetchInboxForAccount` does `/.well-known/jmap` + `Mailbox/get` + `Email/query` + `Email/get` per account per load (`src/api/unified-inbox.ts:175-234`); the active account is also fetched detached instead of via the live client/snapshot.
- Fix hint: cache `apiUrl`/`primaryJmapId`/inbox id per account (keyed by serverUrl+username) for the session; use the live `jmapClient` + existing inbox snapshot for the active account.
### List rendering and behaviour
- [x] **Sent/Drafts rows show the sender ("me") instead of the recipient (1.4.12)** — `P2` — `bugfix-parity` — fixed in 3194b5e
- What WEB does: `showRecipient = role === 'sent' || role === 'drafts'` → uses `email.to[0]` for name/avatar (`components/email/thread-list-item.tsx:128-131`, `579-583`).
- What RN does: `getSenderName` always reads `from` (`src/screens/EmailListScreen.tsx:31-37`, row `:80-81`, avatar `:118`).
- Fix hint: pass `currentMailbox?.role` into `EmailRow` and pick `to[0] ?? from[0]` for sent/drafts (also in `UnifiedInboxScreen` if role views are added).
- [x] **Tags are not rendered on list rows (and no tag row tint)** — `P2` — `missing` — fixed in 3194b5e
- What WEB does: `TagBadge`s per row from `getEmailTagIds` (both `$label:` and legacy `$color:`), thread rows union all messages' tags, optional row tint by first tag (`components/email/thread-list-item.tsx:152-155`, `609-611`, `lib/thread-utils.ts:187-244`, `hooks/use-tag-display.ts`).
- What RN does: `EmailRow` shows no tags; `tagPill/tagDot/tagText` styles exist but are unused (`src/screens/EmailListScreen.tsx:1481-1495`). Tags are only visible inside the thread screen tag menu.
- Fix hint: compute tag ids from `item.keywords` (`$label:*`, `$color:*`), look up `useKeywordsStore().keywords` for label/colour (unknown ids → grey with raw id), render pills on the subject row; add a `tintListRowsByTag` setting if desired.
- [x] **Tag view (tap a tag → cross-folder `hasKeyword` list, #175) and tag counts in the drawer** — `P2` — `missing` — fixed in 6ae21d5
- What WEB does: "Tags" sidebar section with unread/total per tag (`fetchTagCounts`, `stores/email-store.ts:1192`), visibility rules (`components/layout/sidebar.tsx:1002-1013`), `selectKeyword` → `getEmails(undefined, …, '$label:<id>')` across all folders (`stores/email-store.ts:1343-1420`, changelog 1.4.13 #175), counts kept in step with read/unread (1.7.8).
- What RN does: no tag section in `SidebarDrawer`; `EmailFilters` has no keyword field (`src/stores/email-store.ts:140-149`).
- Fix hint: add `keyword?: string` to `EmailFilters` and a "Tags" section in the drawer; when set, `buildJmapFilter` should omit `inMailbox` (search across folders) and add `{ hasKeyword: '$label:<id>' }`; counts via one `Email/query` (calculateTotal) per tag, optional.
- [x] **Answered / forwarded status icons (1.4.8)** — `P3` — `missing` — fixed in 3194b5e
- What WEB does: `Reply`/`Forward` icons from `$answered`/`$forwarded` (`components/email/thread-list-item.tsx:120-121`, `396-412`).
- What RN does: none (no reference to `$answered` anywhere in `src/`).
- Fix hint: add the two icons next to the star/paperclip in `EmailRow`.
- [x] **Pin uses `$important` instead of `$pinned`; no pinned-first order or pin icon** — `P2` — `bug` — fixed in 3194b5e
- What WEB does: pin toggles the `$pinned` keyword (`components/mail/mail-app.tsx:2274-2300`), rows show a `Pin` icon (`thread-list-item.tsx:119`), and the query puts `$pinned` first (`lib/jmap/client.ts:1433-1444`, `buildEmailSort` `lib/message-list-order.ts:165-195`), thread groups too (`lib/thread-utils.ts:82-100`).
- What RN does: `togglePin` writes `$important` (`src/stores/email-store.ts:1151-1173`), `isPinned` reads `$important` (`src/screens/EmailListScreen.tsx:47-49`); no icon, no ordering. A pin set in RN is invisible in WEB and vice versa.
- Fix hint: switch the keyword to `$pinned`; show a pin icon; optionally sort pinned to the top client-side (server sort needs the polarity probe, see next item).
- [x] **Configurable message-list order presets/custom levels (#718) incl. Stalwart `hasKeyword` polarity probe; native issue #5** — `P2` — `partial` — fixed in bea3fc4
- What WEB does: presets (unread/starred/tagged first) or up to 3 levels mapped to the JMAP sort, Inbox-only or all folders (`lib/message-list-order.ts`, `components/settings/message-list-order-settings.tsx:52-62`, `getMessageListOrderFor` `stores/settings-store.ts:1396`); polarity probe per account because Stalwart inverts `isAscending` on keyword comparators (`lib/jmap/client.ts:1376-1431`), `unsupportedSort` fallback (`:1490-1500`); thread grouping mirrors the order (`lib/thread-utils.ts:82`).
- What RN does: only `receivedAt` asc/desc via `mailSortAscending` (persisted, header toggle `src/screens/EmailListScreen.tsx:699-711`, store `setSortAscending` `src/stores/email-store.ts:1039-1053`). This satisfies the "per default" part of native issue #5 but nothing else.
- Fix hint: port `message-list-order.ts` (pure, no deps) and pass `buildEmailSort(levels, …)` as `sort` to `queryEmails`/`getEmailQueryChanges`; probe polarity once per account like `probeKeywordSortPolarity`; add a settings screen with the presets. Keep `setSortAscending`'s snapshot invalidation for any order change.
- [x] **Threading: counts only within the loaded page, no Thread/get counts, no conversation open, representative message wrong in ascending sort** — `P2` — `partial` — fixed in 3194b5e
- What WEB does: groups by `threadId`, `emailCount` from `Thread/get` across folders (`fetchThreadEmailCounts` `stores/email-store.ts:3731-3751`, `lib/thread-utils.ts:14-72`), representative = latest email, mobile tap opens the whole conversation via `getThreadEmails` routed to the owner (#814, `components/mail/mail-app.tsx:3240-3275`), "collapse all threads".
- What RN does: collapses same-`threadId` rows within the page and counts within the page (`src/screens/EmailListScreen.tsx:224-248`); keeps the *first* row encountered, i.e. the oldest when `mailSortAscending` is on; tapping opens only that single message (`EmailThreadScreen` pages over `emails`, no `Thread/get` anywhere).
- Fix hint: `Thread/get` for the visible thread ids (chunked) to get real counts; pick the newest message as representative; a conversation screen belongs to the viewer area but the list should hand over `threadId` + owner account.
- [x] **Drafts do not open in the composer / no "Edit draft"** — `P2` — `missing` — fixed in 3194b5e
- What WEB does: a `$draft` message opens the composer (`handleEditDraft` `components/mail/mail-app.tsx:1812`, list `onEditDraft` `components/email/email-list.tsx:47,616`, context menu `components/email/email-context-menu.tsx:225-233`); viewer shows a draft banner.
- What RN does: tapping a draft opens the read-only `EmailThreadScreen`; `Compose` params have no draft/edit mode (`src/navigation/types.ts:7-24`), locale keys `email_viewer.draft_banner`/`edit_draft` exist but are unused.
- Fix hint: in `handleRowPress`, if `item.keywords.$draft` (or folder role is drafts) navigate to `Compose` with a `draft: { id, to, cc, bcc, subject, body, blobId }` param; on send, destroy the old draft (composer area).
- [ ] **Permanent delete without confirmation (Trash, `deleteAction: permanent`, junk auto-permanent)** — `P2` — `bugfix-parity` — list side fixed in 3194b5e (single, batch, swipe via `src/lib/delete-confirm.ts`); deferred: thread screen `onDelete` belongs to the viewer area — use `confirmPermanentDelete()`/`isPermanentDelete()` from `src/lib/delete-confirm.ts`
- What WEB does: confirm dialog before any permanent destroy, single and batch (`components/mail/mail-app.tsx:2086-2100`, `components/email/email-list.tsx:229-247`).
- What RN does: `deleteEmail`/`deleteEmailsBatch` destroy immediately when in Trash, when `deleteAction === 'permanent'`, or junk + `permanentlyDeleteJunk` (`src/stores/email-store.ts:1286-1298`, `1449-1458`); the swipe fires without any prompt (`EmailListScreen.tsx:369-378`), and no undo is offered (correctly, `:1322-1324`).
- Fix hint: compute `destroy` in the screen before calling the store (same rule) and show `Alert.alert` with Cancel/Delete; same for the thread screen `onDelete`.
- [ ] **"Never" mark-as-read (`markAsReadDelay === -1`) marks read instantly** — `P2` — `rn-only-bug`
- What WEB does: `-1` → never, `0` → instant, else timer (`components/mail/mail-app.tsx:1511-1528`).
- What RN does: `if (markAsReadDelay > 0) { timer } else { markRead() }` (`src/screens/EmailThreadScreen.tsx:236-245`) so the "Never" option in `ReadingSettings.tsx:171` behaves like "Instant".
- Fix hint: add `if (markAsReadDelay === -1) return;` before the branch.
- [x] **Load-more appends duplicates when new mail shifts positions** — `P3` — `rn-only-bug` — fixed in f07e039
- What WEB does: filters `newEmails` by existing ids before appending (`stores/email-store.ts:1620-1625`).
- What RN does: `merged = [...emails, ...newEmails]` with no dedup (`src/stores/email-store.ts:714-716`); a message that arrived between pages appears twice (duplicate FlatList keys, double rows).
- Fix hint: `newEmails.filter(e => !existingIds.has(e.id))`.
- [x] **Incremental-refresh window "cap" is a no-op** — `P3` — `rn-only-bug` — fixed in f07e039
- What RN does: `out.slice(0, Math.max(limit, out.length))` (`src/stores/email-store.ts:885`) never trims although the comment says it caps the window to the page size; harmless but misleading (the snapshot can grow past `emailsPerPage`).
- Fix hint: either drop the line and comment, or use `Math.max(limit, baseEmails.length)` if the intent is "at least the previous window".
- [x] **Swipe "spam" available in Sent/Drafts and no "not spam" in Junk** — `P3` — `rn-only-bug` — fixed in 3194b5e
- What WEB does: spam action hidden for sent/drafts/scheduled and flips to "not spam" inside Junk (`components/email/thread-list-item.tsx:523`, `components/email/email-hover-actions.tsx:117-140`, changelog 1.7.7).
- What RN does: `handleSwipeAction 'spam'` only checks `currentMailboxId !== junkMailboxId` (`src/screens/EmailListScreen.tsx:379-388`), so a swipe in Sent moves your own mail to Junk; in Junk the swipe silently does nothing.
- Fix hint: skip when `currentMailbox.role` is sent/drafts; in junk call an `undoSpam` (see next item) and change the band label to "Not spam".
- [x] **Spam / not-spam do not flip `$junk`/`$notjunk` (#850) nor honour "trash-and-read"** — `P2` — `bugfix-parity` — fixed in 3194b5e
- What WEB does: `markAsSpam` patches `mailboxIds` + `keywords/$junk: true` + `keywords/$notjunk: null` (+ `$seen` when `deleteAction === 'trash-and-read'`); `undoSpam` restores the original mailbox with `$junk: null`, `$notjunk: true` (`lib/jmap/client.ts:2422-2475`, store `stores/email-store.ts:2947-3100`, undo toast in `components/mail/mail-app.tsx:2228-2270`).
- What RN does: swipe and thread-screen spam are plain `moveToMailbox` calls (`src/screens/EmailListScreen.tsx:381`, `src/screens/EmailThreadScreen.tsx:344-356`); the keywords stay untouched, so other clients/Stalwart's classifier never learn; undo label reads "Email moved to Junk".
- Fix hint: add `markAsSpam(ids, junkRawId, accountId, alsoMarkRead)` / `undoSpam(ids, targetRawId, accountId)` to `src/api/email.ts` writing the keyword pointers; store actions with `pendingUndo.kind = 'spam'` (type already exists, `src/stores/email-store.ts:155`) whose `undoLast` also restores the keywords; queue-safe via an outbox `keywords` op.
- [x] **Batch spam / batch not-spam** — `P3` — `missing` — fixed in 3194b5e
- What WEB does: `batchMarkAsSpam`/`batchUndoSpam` (`stores/email-store.ts:3104-3236`), "Not spam" button in the Junk selection toolbar (`components/email/email-list.tsx:396-411`, changelog 1.7.8).
- What RN does: selection header has star/read/tag/move/archive/delete only (`src/screens/EmailListScreen.tsx:572-630`).
- Fix hint: add a shield button that calls the batch spam/undo-spam from the previous item depending on `currentMailbox.role === 'junk'`.
- [x] **Selecting a collapsed thread row selects/acts on only its newest message** — `P2` — `bug` — fixed in 3194b5e
- What WEB does: the thread checkbox toggles every message of the thread (`toggleThreadSelection`, `components/email/thread-list-item.tsx:640-660`); single delete/archive on a thread row uses `moveThreadToMailbox` for archive (`components/mail/mail-app.tsx:2141-2180`).
- What RN does: `selectedIds` holds the representative id only (`src/screens/EmailListScreen.tsx:336-352`, `436-442`), so "3 selected" may be 3 conversations but batch delete/move/archive/tag touch one message each (`deleteEmailsBatch` filters `emails` by id, `email-store.ts:1443`); swipe actions on a thread row likewise act on one message.
- Fix hint: when threading is on, expand each selected representative to all `emails` with the same `threadId` before calling the batch store actions (and for swipe archive/delete/move on rows with `threadCount > 1`).
- [x] **Keep search/filters when switching folders (#553)** — `P3` — `bugfix-parity` — fixed in f07e039
- What WEB does: re-runs the active text/advanced search in the newly selected folder (`components/mail/mail-app.tsx:2501-2512`).
- What RN does: `selectMailbox` resets `searchQuery` and `filters` (`src/stores/email-store.ts:676-677`) and the list input follows (`EmailListScreen.tsx:504-511`).
- Fix hint: keep `searchQuery`/`filters` in `selectMailbox` (seed the base snapshot only when they are empty) so the search re-runs in the new folder; offer "Clear" as today.
- [x] **Just-read mail dropped from an open Unread-filtered list on the next push refresh** — `P3` — `bugfix-parity` — fixed in f07e039
- What WEB does: retains rows the user just read/unstarred in the Unread/Starred views until the view is re-opened (`stores/email-store.ts:1007-1050`, `mergeRetainedRows`; changelog 1.9.0).
- What RN does: `markRead` patches the row in place, but the next `refreshEmails` (push or pull) re-runs the `notKeyword $seen` query (`src/stores/email-store.ts:279`, `917-944`) and the row vanishes while the user is looking at it.
- Fix hint: keep a `retainedIds` set per filter session; after a full re-query, splice retained rows (from the previous `emails`) back at their old index.
- [x] **Search runs on every keystroke** — `P3` — `rn-only-bug` — fixed in 3194b5e
- What WEB does: searches on submit / suggestion pick (`components/search/search-box.tsx:108-115`).
- What RN does: 300 ms debounce into `setSearchQuery` → full `Email/query` + `Email/get` per pause (`src/screens/EmailListScreen.tsx:512-516`); with the `*` wildcard a single letter matches the whole mailbox.
- Fix hint: search on `onSubmitEditing` (keep the clear button live), or debounce ≥ 600 ms with a minimum of 2 characters.
- [x] **Date format: no regional date-locale setting; relative strings hard-coded** — `P3` — `partial` — fixed in 0205aa0
- What WEB does: user-selectable regional format (`dateLocale`, changelog 1.7.7) and a preset picker (#331); strings localized.
- What RN does: `formatListDate` follows the app locale only (`src/lib/date-format.ts:17-31`), "Just now"/"m ago" are English (`:38-41`).
- Fix hint: localize the relative strings via `t()`; add a `dateLocale` select if parity is wanted.
- [x] **Show avatars in Junk (off by default, 1.5.1); avatar priority** — `P3` — `partial` — fixed in bea3fc4
- What WEB does: `showAvatarsInJunk` hides favicon/photo images in Junk (`components/email/thread-list-item.tsx:137`); avatar priority contact photo > plugin (Gravatar) > favicon > initials (`components/ui/avatar.tsx:236`).
- What RN does: favicons always load, including in Junk (`src/components/SenderAvatar.tsx:37`); no contact-photo lookup.
- Fix hint: pass `disableImages={currentMailbox.role === 'junk' && !showAvatarsInJunk}` into `SenderAvatar`; contact photos optional.
- [x] **Per-message actions from the list (mobile long-press menu)** — `P3` — `partial` — fixed in 3194b5e
- What WEB does: long-press on mobile opens the context menu (reply/forward/move/tag/pin/spam/mark read/copy link) (`components/email/thread-list-item.tsx:181-190`, `components/email/email-context-menu.tsx`).
- What RN does: long-press enters selection (`EmailListScreen.tsx:98,425`); single-message move/tag/spam need a swipe (two configurable directions) or opening the message.
- Fix hint: optional — selection mode already exposes most actions; add "spam" to the selection header (see above) and this is close enough.
- [x] **Own → shared/group folder move (cross-account move, 1.7.2)** — `P3` — `missing` — fixed in 0205aa0
- What WEB does: copy+delete via `crossAccountMoveEmails`, drop into shared mailboxes allowed (`stores/email-store.ts:1980-2026`, `2169`).
- What RN does: refuses with "Messages can only be moved within the same account" (`src/stores/email-store.ts:1183-1186`, `1396-1399`); `MoveSheet` is scoped to the same account so the message is only reachable by a future picker. `MoveSheet` also allows Drafts as a target (`MoveSheet.tsx:102-103`) which WEB excludes (`email-context-menu.tsx:163`).
- Fix hint: exclude `role === 'drafts'` targets; cross-account move = `Email/get` blob → `Blob/upload` to the owner → `Email/import` → destroy (import helper already exists in `src/api/email.ts:423`).
- [x] **Undo snackbar / toast strings not localized; move toast lacks folder path** — `P3` — `partial` — fixed in 3194b5e
- What WEB does: toasts localized, full folder path in move toast (changelog 1.5.0).
- What RN does: hard-coded English labels in the store (`src/stores/email-store.ts:1202, 1252, 1328, 1379, 1425, 1520`), "UNDO" (`UndoSnackbar.tsx:73`), list empty/loading strings (`EmailListScreen.tsx:805-830`), filter modal labels, `MoveSheet`/`TagSheet` titles.
- Fix hint: route through `useLocaleStore().t` (keys already exist for many: `email_list.*`, `notifications.*`).
### Search
- [x] **Search folder scope: always the current folder; no all-folders default (#788) and no folder picker** — `P2` — `bugfix-parity` — fixed in 3194b5e
- What WEB does: `searchMailboxId` defaults to `""` = all folders, is a separate, persisted choice in the filter panel (`stores/email-store.ts:95-101`, `2344-2378`, changelog 1.9.0 #788); cross-mailbox queries.
- What RN does: `buildJmapFilter` always starts with `{ inMailbox: current }` (`src/stores/email-store.ts:255`); the filter modal has no folder field.
- Fix hint: add `folder?: string | 'all'` to `EmailFilters` (default `'all'` while a text query or filter is active, i.e. omit `inMailbox`); expose a folder select in the modal; the base-view snapshot logic already keys off `isBaseView`.
- [x] **Body filter, per-chip removal** — `P3` — `partial` — fixed in 3194b5e
- What WEB does: `body` condition (`lib/jmap/search-utils.ts:62-64`); each chip has an X (`components/search/search-chips.tsx:260-266`).
- What RN does: no `body` field; chips are read-only, only "Clear" all (`src/screens/EmailListScreen.tsx:725-796`).
- Fix hint: add `body` to `EmailFilters`/`buildJmapFilter`; make each chip's X call `setFilterField(key, undefined)`.
- [x] **Search suggestions and recent-search history (#845)** — `P3` — `missing` — fixed in 3194b5e
- What WEB does: recent searches (max 10, persisted) + contact/sender matches under the search box, keyboard navigable, pick a contact → `from:`/`to:` filter (`stores/search-history-store.ts`, `lib/search-suggestions.ts:183`, `components/search/search-box.tsx:34`, `components/mail/mail-app.tsx:2807-2826`).
- What RN does: plain `TextInput` (`EmailListScreen.tsx:677-698`).
- Fix hint: small zustand store persisted in AsyncStorage; render a dropdown under the input when focused (recent + `useContactsStore` matches); tapping a contact sets `filters.from`.
### Tags / keywords
- [ ] **Tag definition features: visibility, nesting/parent, reorder, rename with migration, unknown tags** — `P3` — `partial` — partial in 3194b5e: unknown `$label:` ids listed/removable in TagSheet; deferred: visibility, nesting, reorder, rename migration (device-local tag model, see native issue #1)
- What WEB does: `visibility: show|unread|hide` (`stores/settings-store.ts:180-190`, `components/settings/keyword-settings.tsx:98-132`), nested ids `parent/child` (`lib/keyword-nesting.ts`, `keyword-settings.tsx:213-243`), drag reorder (atomic, changelog 1.9.0), rename keyword migrates messages (`renameKeyword`/`client.migrateKeyword` `lib/jmap/client.ts:1963`), tag picker lists unknown ids present on a message so they can be removed (`components/email/tag-picker.tsx:484-491`).
- What RN does: `{ id, label, color }` only (`src/stores/keywords-store.ts:9-13`); `TagSheet` lists defined tags only (`src/components/TagSheet.tsx:76-95`), so a `$label:work/clients` set by WEB can neither be seen nor removed on RN. Tag definitions are device-local (see native issue #1).
- Fix hint: show unknown `$label:*` ids found on the selected emails at the bottom of `TagSheet`; add `visibility` when the drawer tag section lands; nesting/reorder are lower priority.
- [x] **"Reset defaults" wipes the tag list with no confirmation (removed in WEB 1.8.1)** — `P3` — `bugfix-parity` — fixed in 6ae21d5
- What WEB does: removed the button ("one stray click wiped a carefully built tag list", changelog 1.8.1 Changes).
- What RN does: `KeywordSettings.tsx:94-97` → `resetDefaults()` immediately (`src/stores/keywords-store.ts:85-88`).
- Fix hint: remove the button or guard with `Alert.alert` confirm.
- [x] **Scan for keywords no local tag explains (#658)** — `P3` — `missing` — fixed in 6ae21d5
- What WEB does: `discoverKeywords` pages `Email/query`+`Email/get keywords` and proposes label/colour for unknown `$label:`/`$color:` ids (`lib/jmap/client.ts:1616-1676`, `lib/keyword-discovery.ts:140`, `components/settings/keyword-settings.tsx:374-490`).
- What RN does: none; after a reinstall all tags must be re-typed with the exact id.
- Fix hint: port `discoverKeywords` (cap 5000 like offline sync) + `findUnrecognizedKeywords` (pure) and an "Add" list in `KeywordSettings`.
- [x] **Keyword palette parity** — `P3` — `partial` — fixed in 6ae21d5
- What WEB does: `KEYWORD_PALETTE` keys (`stores/settings-store.ts:226-280`) are the tag ids of the default tags and the colour names used by `suggestKeywordColor`.
- What RN does: `colors.tags` keys in `src/theme/tokens.ts`; defaults are `important/work/personal/todo` (`src/stores/keywords-store.ts:15-20`) whereas WEB's `DEFAULT_KEYWORDS` (`settings-store.ts:281`) are colour-named. A message tagged on one client with a default tag shows as unknown on the other.
- Fix hint: verify the key sets match; consider aligning the default ids with WEB's.
### Offline cache (RN only, sanity check)
- [x] **Offline sync covers the primary account only; shared folders never cached** — `P3` — `rn-only-bug` — fixed in 0205aa0
- What RN does: `runOfflineSync` discovers via `queryEmailsByFilter` which hard-codes `jmapClient.accountId` (`src/lib/offline-sync.ts:55`, `src/api/email.ts:733-747`), so opening a group-folder message offline always fails; `selectMailbox` seeding for a shared folder therefore always yields nothing (`src/stores/email-store.ts:647-666` looks up by raw id, which is correct).
- Fix hint: iterate `jmapClient.getSharedMailAccounts()` in the sync with `accountIdOverride`, storing the account id in the cache index (`getFullEmails(ids, accountId)` already exists).
## Verified at parity (do not redo)
- Folder tree: nesting, per-account grouping of shared/group accounts with unread roll-up, expanded state persisted, role priority sort (`src/lib/mailbox-tree.ts`, `SidebarDrawer.tsx`); create/rename/delete own folders with server error surfaced (`FolderSettings.tsx`); delete-with-emails confirm.
- Shared/group folders: queries and every mutation (read/star/pin/tag/move/archive/delete/undo/import) routed to the owner account with the unprefixed id (`refFor`, `src/stores/email-store.ts:64-99`); thread screen fetches by owner (`EmailThreadScreen.tsx:90-96`); state-change handling per account (`email-store.ts:981-1015`).
- Folder tree preserved when a refresh fails (#780 equivalent, `email-store.ts:604-612`), shared-account failure isolated (`:595-601`); Mailbox/changes incremental sync with drain.
- Missing Archive/Trash/Junk → error alert (`EmailListScreen.tsx:363-387`), never silently destroys when Trash is missing (#195 equivalent).
- Delete semantics: trash / trash-and-read (#323) / permanent / junk auto-permanent, single and batch (`email-store.ts:1268-1335`, `1434-1527`); undo snackbar for archive/delete/move (single + batch) — RN is ahead of WEB here.
- Archive single / by year / by month with folder creation in one request, batch archive, "Reorganize existing archive" (`src/api/email.ts:579-692`, `ReadingSettings.tsx:105-159`).
- Swipe actions: configurable per direction, instant and reveal modes, pure gesture module with tests, stale-props fix (`SwipeableRow.tsx`, `swipe-gesture.ts`); more actions than WEB (pin, move). RTL: RN ships no RTL locales, nothing to do yet.
- Multi-select: long-press, select-all-visible/indeterminate, batch star/read/tag/move/archive/delete (`EmailListScreen.tsx:320-500`).
- Pagination via `onEndReached`, pull-to-refresh, incremental `Email/queryChanges` + `Email/changes` refresh with per-account state tokens, base-view restore after search (#10), sort change invalidates snapshots (#5), offline seed and fallback.
- Search: full-text with wildcard suffix (`src/lib/search-utils.ts` = WEB `toWildcardQuery`), from/to/subject/date/attachment/unread/starred tri-state filters, chips row, search inside a shared folder routed to its owner.
- Keyword writes send the whole `keywords` map (no JSON-pointer escaping issue, no `false` values) — not affected by 5c484af1 / be97c8bf; tag ids normalized exactly like WEB `normalizeKeywordLevel` (`KeywordSettings.tsx:118-123`); batch tag toggle via `TagSheet` with "all have it" semantics.
- Unread dot (#27), date formats smart/relative/full with 12/24h, preview toggle, density-aware rows, sender favicon avatars with failure cache, `.eml`/`.zip` import into the open folder, Scheduled quick view, "Include group inboxes" setting and shared badge/account dot in the unified list.
- Mark-as-read delay (0/3s/5s) in the thread screen; both clients still display `receivedAt` in the list (WEB `lib/email-date.ts` is not wired into the list yet).
## N/A on mobile
- Category tabs / message-list tabs (`stores/message-list-tabs-store.ts`) — plugin-registered; RN has no plugin runtime.
- Hover quick actions, keyboard navigation/shortcuts, right-click context menus, drag-and-drop of mail onto folders/tags and folder-tab drag (Pro) — desktop interactions; RN has swipe + selection instead.
- Admin policy gates for unified/cross views (`usePolicyStore` feature flags) — RN has no admin policy channel; only the user-side toggles apply.
- Return-to-list-after-action setting — the RN thread screen always returns after delete/archive/spam.
- "Refresh" toolbar button and F5/Ctrl+R interception — pull-to-refresh covers it.
- Favicon unread badge, sidebar collapse/resize, Pro multi-pane shell, folder subscription (neither client exposes `isSubscribed`).
- Plugin search hooks / external search results, `onEmailsFetched` transforms.
- Source-folder chip on rows is N/A *until* All-mail/cross views exist in RN.
+266
View File
@@ -0,0 +1,266 @@
# Email viewer / thread view / message rendering / attachments
## Summary
RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pinch zoom, external-content gate with trusted senders, cid inlining, attachment open/share/save with templated filenames, view source, .eml export, calendar-invitation banner, shared-folder routing for body/attachments/source) but it is a *single-message* viewer, not a thread view, and it never fetches `headers`/`messageId`/`inReplyTo`/`references`, so everything the WEB viewer derives from headers (SPF/DKIM/DMARC, List-Unsubscribe, read receipts, Reply-To, identity badge, correct `In-Reply-To`) is absent or wrong. The biggest gaps: (1) older messages of a thread are unreachable and the pager shows the wrong message when opened from the unified inbox / contact activity; (2) replies write the JMAP id into `In-Reply-To`, ignore `Reply-To`, do not strip the user's own address on reply-all, and forwards drop every attachment; (3) HTML-only messages (same part in `textBody` and `htmlBody`) are rendered and quoted as raw HTML source, and `<style>` blocks are stripped wholesale, which is the most likely root of native issues #46 ("email preview shows css") and #49 ("Unreadable text in dark-mode emails"); (4) external-content blocking only recognises `<img src=http…>`, `background=` and inline `url()`, so srcset/`<source>`/poster/whitespace-obfuscated pixels still load.
## Findings
### Thread view / navigation
- [x] **Thread messages other than the newest are unreachable — fixed in d2ed27f** — `P1` — `missing`
- What WEB does: opening a thread renders every message of the thread as expandable cards (newest + all unread auto-expanded), each with its own body, attachments, reply/forward and mark-read-on-expand (`components/email/thread-conversation-view.tsx:98-115`, `171-200`, store `fetchThreadEmails` via `Thread/get` + `Email/get` in `stores/email-store.ts:3574-3640`, `lib/jmap/client.ts:2763-2800`).
- What RN does: the list collapses same-thread rows to the newest message with a count badge (`src/screens/EmailListScreen.tsx:224-238`, default `disableThreading: false` at `src/stores/settings-store.ts:266`), the row opens `EmailThreadScreen` with a single `emailId` (`App.tsx:145-151`), and the screen only ever shows that one message (`src/screens/EmailThreadScreen.tsx:113-120`, `685-840`). `getThread` exists in `src/api/email.ts:448` but is only used by a test. Older messages of a thread cannot be opened at all unless the user turns on "Disable Thread Grouping".
- Fix hint: on open, call `getThread(threadId, ownerAccountId)` + `getFullEmails(ids)` and render a vertical list of collapsible message cards inside the pane (newest + unread expanded), reusing `EmailBodyView` per card; keep the horizontal pager for thread-to-thread navigation.
- [x] **Pager shows the wrong message (or nothing) when the opened email is not in the store list — fixed in c8be383** — `P1` — `rn-only-bug`
- What WEB does: the viewer renders `selectedEmail` directly; prev/next are derived from the current list (`components/email/email-viewer.tsx:5220-5279`).
- What RN does: the pager is a `FlatList` over `useEmailStore(s => s.emails)` and the initial index is `Math.max(0, emails.findIndex(id))` (`src/screens/EmailThreadScreen.tsx:128-133`, `535-572`). `UnifiedInboxScreen` keeps its own local list and never writes the store (`src/screens/UnifiedInboxScreen.tsx:35-50`, `61-84`), `ContactActivity` navigates with ids from a contact search (`src/components/contacts/ContactActivity.tsx:170-177`). In both cases the opened id is absent from `emails`, so the visible pane is `emails[0]` (a different mailbox's first message, or blank when the list is empty) while the toolbar/mark-read/delete act on `activeEmailId`.
- Fix hint: when `route.params.emailId` is not in `emails`, render a single-item pager (`data=[{id: emailId}]`) and disable prev/next; or have the unified inbox / contact activity pass their own id list through route params.
- [x] **Mark-as-read "Never" (-1) marks the message read instantly — fixed in d2ed27f** — `P2` — `rn-only-bug`
- What WEB does: `-1` means never auto-mark, `0` instant, `>0` delayed (`components/email/email-viewer.tsx:1218-1238`).
- What RN does: `ReadingSettings` offers `Never = -1` (`src/components/settings/ReadingSettings.tsx:167-173`) but the viewer does `if (markAsReadDelay > 0) {timer} else { markRead() }` (`src/screens/EmailThreadScreen.tsx:237-245`), so `-1` behaves like instant.
- Fix hint: add `if (markAsReadDelay === -1) return;` before the branch.
- [x] **Skeleton / loading parity — verified, no action** — `P3` — `verified` (see "Verified at parity"); no action.
### Body rendering & sanitisation
- [x] **HTML-only messages are rendered (and quoted) as raw HTML source — native issue #46 "email preview shows css" — fixed in 06742ef** — `P1` — `bug`
- What WEB does: per RFC 8621 §4.1.4 an HTML-only message exposes the same part in `textBody` and `htmlBody`; WEB checks `hasDistinctTextBody` (different partIds) before preferring the text alternative, and routes by the part's `type` (`components/email/email-viewer.tsx:1727-1747`, `components/email/thread-conversation-view.tsx:332-346`; changelog 1.6.3 "Render HTML-only emails", "Render plain-text-only emails as text, not HTML").
- What RN does: `extractTextBody` returns the html part's value for HTML-only mail, then `rawHtml = html && (!text || hasMeaningfulHtmlBody(html)) ? html : null` (`src/components/EmailBodyView.tsx:43-57`, `504-511`). For any HTML-only message whose markup fails `MEANINGFUL_HTML_RE` (`src/lib/email-html.ts:353-362`, e.g. a `<div>`/`<p>` body without `style=`), `rawHtml` is null and the raw source — tags, `<head>`, CSS — is shown as plain text. The same value feeds the reply quote: `plainTextBody()` (`src/screens/EmailThreadScreen.tsx:59-65`, `377`) so replies to HTML-only mail quote escaped HTML source.
- Fix hint: compare `textBody[0].partId !== htmlBody[0].partId` (and `part.type === 'text/html'`) before treating `text` as an alternative; in `navigateCompose` pass `htmlBody` (and let `buildInitialHtml` use it) or convert with `htmlToPlainText` (`src/lib/compose-html.ts:108`) when the only body is HTML.
- [x] **`<style>` blocks are stripped from every HTML email — layout/colour loss, likely native issue #49 "Unreadable text in dark-mode emails" — fixed in 06742ef** — `P2` — `bugfix-parity`
- What WEB does: iframe sanitiser keeps `<style>` (`lib/email-sanitization.ts:60-78`), and when external content is blocked it neutralises `url()`/`@import`/`@font-face` inside the sheet (`stripExternalStyleSheetCss`, `lib/email-sanitization.ts:474-509`, changelog 1.7.8 #457) while the strict iframe CSP is the network backstop (`components/email/email-viewer.tsx:2265-2267`).
- What RN does: `FORBID_TAGS` includes `'style'` (and `svg`) so every stylesheet is removed before render (`src/lib/email-html.ts:16-28`, `50-68`). Class-based colours vanish: a newsletter with white text via `.hero { color:#fff }` on a `bgcolor` cell renders white-on-white (light) or, after the invert filter, black-on-black. Also `hasNativeDarkMode` is evaluated on the *stripped* HTML inside `wrapEmailHtml` (`src/lib/email-html.ts:226-229`) but on the raw HTML in the component (`src/components/EmailBodyView.tsx:612-613`), so for emails with `@media (prefers-color-scheme: dark)` the CSS inversion is applied but the DOM re-invert pass is skipped.
- Fix hint: keep `<style>` (the WebView is an isolated document; CSP already has `style-src 'unsafe-inline'`), port `stripExternalStyleSheetCss` + `decodeCssEscapes` for the blocked case, and evaluate `hasNativeDarkMode` once on the same input.
- [x] **Dark-mode inversion CSS is an older revision of WEB's — fixed in d2ed27f** — `P2` — `bugfix-parity`
- What WEB does (1.7.4 "Correct dark-mode background-image inversion and height clipping", 1.6.3 emoji/nested bgcolor, 1.7.7 `height:100%` wrappers): background-*image* containers are re-inverted unconditionally and their media get `filter:none`; only bgcolor/`background:` containers use the `:not(:has(media))` guard; nested guard is limited to colour containers; `[style*="height:100%"]` is neutralised; Word/Outlook HTML gets `line-height:1.15` + `MsoNormal` margins and a fallback gutter; `messageSpacing` auto/edge gutter (`components/email/email-viewer.tsx:2200-2253`, `2287-2305`, `2438-2461`).
- What RN does: `DARK_INVERSION` applies the `:not(:has(...))` guard to background-image containers too and includes them in the nested `filter:none` rule (`src/lib/email-html.ts:167-185`), the DOM pass only re-inverts containers without media children (`src/components/EmailBodyView.tsx:72-88`); no Word/Outlook padding, no per-message light/dark toggle (WEB toolbar Sun/Moon, `email-viewer.tsx:3199-3212`, changelog 1.6.0), no `messageSpacing`.
- Fix hint: copy the current `darkModeCSS` block and the DOM re-invert loop verbatim into `email-html.ts` / `DARK_REINVERT_SCRIPT`; add a "View in light/dark mode" entry to the More sheet that overrides `renderAsDark` for the current message.
- [x] **External-content detection misses most tracking vectors (privacy leak) — fixed in 06742ef** — `P2` — `bugfix-parity`
- What WEB does: `isExternalResourceUrl` strips C0/space so `"\nhttps://x"` and `h\ttps://` count; blocks `<img srcset>`, `<picture><source>`, `<video poster>`, media `src`, `background=`, inline `url()` incl. CSS escapes, `<style>` url()/@import, and forbids `<link>` in blocking mode; the banner is driven by "something was actually blocked" (`lib/email-sanitization.ts:399-406`, `511-615`, `108-150`; changelog 1.7.8).
- What RN does: `hasRemoteContent` only matches `<img src="http…">`, `background="http…"` and `style="...url(http…)"` (`src/lib/email-html.ts:364-369`); when it returns false `shouldBlock` is false and the CSP becomes `img-src data: cid: https: http:` (`src/lib/email-html.ts:235`), so a pixel expressed as `srcset=`, `<source>`, `poster=`, `src=" https://…"` (leading whitespace/newline), or a CSS-escaped `\68ttps://` loads unconditionally under policy `ask`/`block`. `blockRemoteImageSrcs` (`316-347`) is likewise src/srcset/background/style only, and has no `data-blocked-*` restore.
- Fix hint: make the gate policy-driven rather than detection-driven (block = strict CSP `img-src data: cid:` + `media-src`/`font-src` none whenever policy is `block`/`ask`-not-allowed and sender untrusted, like WEB's `externalBlocked`), port `isExternalResourceUrl` normalisation, add `source/video/audio/poster/link` handling, and show the banner when the strict CSP is in effect and the HTML contains any `http(s)://` resource reference.
- [x] **Blocked images leave broken-image icons / alt text; empty containers not collapsed — fixed in 06742ef** — `P3` — `bugfix-parity`
- What WEB does: swaps `src` for a transparent 1x1 GIF, empties `alt`, `display:none`, and collapses empty `<td>/<div>` wrappers (`lib/email-sanitization.ts:319-320`, `537-546`, `678-704`); hides images that fail to load (`components/email/email-viewer.tsx:2393-2411`, changelog 1.7.7).
- What RN does: sets `src=""` and `alt="[remote image blocked]"` (`src/lib/email-html.ts:319-323`), so blocked newsletters show rows of broken-image glyphs with English alt text; no failed-image hiding.
- Fix hint: use the same transparent-GIF + `display:none` swap and port `collapseBlockedImageContainers` as a regex/DOM pass in the injected script; add an `img` `error` listener that hides the element.
- [x] **mailto: links inside the body open the OS mail handler instead of the app composer — fixed in 06742ef** — `P2` — `missing`
- What WEB does: `mailto:` opens the built-in composer with parsed to/subject/body (changelog 1.8.1 "Open mailto: links in the built-in composer"; iframe click handler leaves `mailto:` to the app's protocol handling, `components/email/email-viewer.tsx:2420-2436`).
- What RN does: `onShouldStartLoadWithRequest` hands `mailto:` to `Linking.openURL` (`src/components/EmailBodyView.tsx:715-724`), so tapping an address in an email opens Gmail/Apple Mail instead of Bulwark.
- Fix hint: parse `mailto:` (address, `subject`, `body`, `cc`) and `navigation.navigate('Compose', { prefillTo, ... })`; the `prefillTo` param already exists (`src/screens/ComposeScreen.tsx:292-298`).
- [x] **`data:` URL navigations replace the email body — fixed in 06742ef** — `P3` — `rn-only-bug`
- What RN does: `onShouldStartLoadWithRequest` returns `true` for any `data:` URL (`src/components/EmailBodyView.tsx:717-719`), and `safeUri` keeps `href="data:image/…"` (`src/lib/email-html.ts:109-113`); tapping such a link navigates the WebView to the image with no way back. Only the initial `about:blank` load should be allowed.
- Fix hint: allow `data:` only when `request.navigationType === 'other' && !request.isTopFrame`-style initial loads, otherwise return false (optionally open images externally).
- [x] **Regex sanitiser + `script-src 'unsafe-inline'`: keep the data-URI/SVG allowlist in sync with WEB — fixed in 06742ef** — `P3` — `partial`
- What WEB does: allows only raster `data:image/(png|jpe?g|gif|webp|bmp|avif|x-icon|vnd.microsoft.icon)` on media tags, re-checks `srcset` candidates individually, forbids SVG data URIs and `<svg>` (`lib/email-sanitization.ts:24`, `330-388`), and the iframe has no scripting at all.
- What RN does: `safeUri` allows any `data:image/*` including `image/svg+xml` (`src/lib/email-html.ts:109-113`), `srcset` values are never scheme-checked except when blocking (`326-329`), and the page CSP must allow `'unsafe-inline'` scripts for the injected bridge (`236-245`), so a parser-differential bypass of the regex stripper would execute script in the WebView (limited blast radius: the bridge only accepts height/swipe/zoom strings).
- Fix hint: restrict `data:` to the raster list, scheme-check every `srcset` candidate, and consider parsing with a real HTML parser (e.g. `htmlparser2`/`parse5` on the JS side) instead of regexes; document the residual risk.
- [x] **Quoted reply text is not collapsed (#480) — fixed in 06742ef** — `P3` — `missing`
- What WEB does: hides the trailing quote (Gmail/Outlook/Apple/Thunderbird/Bulwark markers, attributed `<blockquote>`, `>`-lines in plain text) behind a "•••" toggle (`lib/quote-collapse.ts:143-197`, `219-259`; changelog 1.7.8).
- What RN does: nothing; long threads show every quoted original inline.
- Fix hint: port `setupQuoteCollapse` into the injected WebView script (runs on the parsed DOM, uses inline styles only) and `collapsePlainTextQuotes` into `wrapPlainTextEmail`.
- [x] **Plain-text bodies are always monospace (#830) — fixed in 06742ef** — `P3` — `bugfix-parity`
- What WEB does: renders text/plain in the app font by default with a `plainTextFont: 'sans' | 'mono'` setting (`stores/settings-store.ts:329`, `565`; `components/email/email-viewer.tsx:5050-5060`; changelog 1.9.0).
- What RN does: `wrapPlainTextEmail` hard-codes `ui-monospace, Menlo, …` (`src/lib/email-html.ts:290`); no setting (`src/stores/settings-store.ts` has no `plainTextFont`).
- Fix hint: add the setting (Reading settings) and switch the font-family in `wrapPlainTextEmail`.
- [x] **Plain-text linkifier only catches http(s) — verified, no action** — `P3` — `verified` (same as WEB `plainTextToSafeHtml`); no action.
- [x] **cid images larger than 2 MB and non-image cid parts silently show a 1x1 placeholder; octet-stream cid parts (#543) unverified — fixed in 06742ef** — `P3` — `partial`
- What WEB does: fetches every cid part as an authenticated `blob:` URL regardless of size/type; the browser sniffs `application/octet-stream` image bytes (`components/email/email-viewer.tsx:1509-1575`; changelog 1.8.1 #543).
- What RN does: skips parts over `MAX_INLINE_IMAGE_BYTES` (`src/components/EmailBodyView.tsx:15`, `556-559`) and builds `data:${att.type || 'application/octet-stream'};base64,…` (`573`), which Chromium/WebKit may refuse to decode as an image for `application/octet-stream`.
- Fix hint: sniff magic bytes (PNG/JPEG/GIF/WebP) to pick the MIME for the data URI; for >2 MB parts encode off the JS thread (e.g. `File.base64()` from expo-file-system after `downloadInto`) instead of dropping them.
### Header / meta
- [x] **`headers`, `messageId`, `inReplyTo`, `references` are never fetched — fixed in ccbe67c** — `P2` — `missing` (root cause for the next five items)
- What WEB does: `Email/get` requests `messageId, inReplyTo, references, headers, bodyStructure` and parses `Authentication-Results`, `X-Spam-*`, `X-Spam-LLM` into `email.authenticationResults/spamScore/spamLLM` (`lib/jmap/client.ts:1775-1870`).
- What RN does: `EMAIL_FULL_PROPERTIES` stops at `bodyStructure, textBody, htmlBody, bodyValues, attachments, blobId, bcc, replyTo, sentAt` (`src/api/email.ts:11-15`); the `Email` type has no `headers`/`messageId` (`src/api/types.ts:65-87`).
- Fix hint: add the four properties to `EMAIL_FULL_PROPERTIES`, extend the type, and port `parseAuthenticationResults`/`parseSpamScore`/`extractListHeaders` (`lib/email-headers.ts`) to `src/lib/email-headers.ts`.
- [x] **No "Show details" panel: CC/BCC/Reply-To/sent vs received/size/MIME/Message-ID/thread id — fixed in d2ed27f** — `P2` — `missing`
- What WEB does: header shows To/CC/BCC (first 2 + "+N", "me" substitution), a "Show details" toggle with recipients & routing, delivery delta, identifiers/threading, message properties, mailing-list section (`components/email/email-viewer.tsx:3892-3944`, `4187-4542`).
- What RN does: one line `to <names>` (`src/screens/EmailThreadScreen.tsx:806-809`); CC/BCC/Reply-To are fetched but never shown; date/size only (`811-817`).
- Fix hint: add CC/BCC rows and a collapsible details block under the sender row; reuse `formatSize`.
- [x] **SPF/DKIM/DMARC/spam-score chips and spoof-aware identity badge — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: auth chips with severity-ranked SPF (HELO vs MAIL FROM, #650), DMARC policy, iprev, spam score/LLM verdict (`components/email/email-viewer.tsx:4353-4438`, `lib/email-headers.ts:13-115`); "via <identity>" / sub-address tag badge hidden when spoofed (`components/email/email-identity-badge.tsx:33-68`, changelog 1.7.3).
- What RN does: none.
- Fix hint: after the headers finding, render chips in the details block; port `isAuthenticationSpoofed` for the badge.
- [x] **List-Unsubscribe (RFC 2369) banner — fixed in d2ed27f** — `P2` — `missing`
- What WEB does: parses `List-Unsubscribe` (http preferred, else mailto), confirm dialog, sends the mailto unsubscribe through the account itself, remembers dismissals per Message-ID (`components/email/unsubscribe-banner.tsx:64-106`, `components/email/email-viewer.tsx:753-773`, `2672-2680`; changelog 1.7.7).
- What RN does: none.
- Fix hint: port `parseUnsubscribeUrls`/`isValidUnsubscribeUrl` from `lib/validation.ts`; for mailto call `sendEmail` with the parsed fields; open http via `expo-web-browser`.
- [x] **Read receipts (MDN, RFC 8098): request banner, send, "always/ask/never", `$mdnsent` — fixed in d2ed27f** — `P2` — `missing`
- What WEB does: detects `Disposition-Notification-To`, offers Send/Ignore (or auto-sends in "always" mode) only in received folders, builds the multipart/report with `lib/mdn.ts`, uploads → `Email/import` into Sent → `EmailSubmission/set` with explicit envelope, then sets `$mdnsent` (`components/email/email-viewer.tsx:2686-2784`, `lib/jmap/client.ts:4080-4130`, `components/email/read-receipt-banner.tsx`).
- What RN does: none; no `readReceiptResponse` setting.
- Fix hint: port `lib/mdn.ts` verbatim (pure string builder), reuse `uploadBytes` (`src/api/blob.ts:67`) + `importEmailBlob` (`src/api/email.ts:423`) + an `EmailSubmission/set` helper; add the setting.
- [x] **Reply-To shown / used — fixed in 7f956d6** — folded into the reply-recipients finding below.
- [x] **Sender / recipient tap actions (popover, view/add contact, copy address) — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: every address is a `RecipientPopover` (contact lookup, extra emails/phones, copy, email, view/add contact); tapping the avatar opens the contact sidebar or, on mobile, the contact page (`components/email/recipient-popover.tsx`, `components/email/email-viewer.tsx:1140-1170`, `3841-3866`).
- What RN does: names are static `Text` (`src/screens/EmailThreadScreen.tsx:794-810`).
- Fix hint: wrap sender/recipients in `Pressable` opening a small action sheet (copy, compose to, open `ContactDetail`/`ContactForm` prefilled).
- [x] **Displayed date uses `receivedAt` and device locale, ignoring the app's time-format setting — fixed in d2ed27f** — `P3` — `bugfix-parity`
- What WEB does: `emailDisplayDate` prefers `sentAt` (falls back when missing or >24 h in the future, #891) and formats with the user's `timeFormat`/locale (`lib/email-date.ts:33-45`, `components/email/email-viewer.tsx:3811`).
- What RN does: `formatHeaderDate(email.receivedAt)` with `toLocaleDateString(undefined, …)` (`src/screens/EmailThreadScreen.tsx:39-50`, `812-816`), so imported/migrated mail shows the import date and 12/24 h ignores the setting.
- Fix hint: port `emailDisplayDate` (already in `src/lib/date-format.ts`?—if not, 15 lines) and use `useSettingsStore(s => s.timeFormat)` + locale from `useLocaleStore`.
- [x] **Tags, `$important` badge and answered/forwarded state in the header — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: tag badges (removable) under the subject, "Important" pill (`components/email/email-viewer.tsx:3789-3806`); `$answered`/`$forwarded` set after send (`components/mail/mail-app.tsx:1663-1667`).
- What RN does: Tag sheet exists but no badges are rendered in the pane; `$answered`/`$forwarded` are never set after a reply/forward (no hit for `$answered` in `src/`).
- Fix hint: render `keywordDefs` matches from `email.keywords` under the subject; after a successful send in `ComposeScreen` call `setEmailKeywords(originalId, {...keywords, $answered|$forwarded: true}, accountId)`.
### Calendar invitation banner
- [x] **Invitation parsing is not routed to the owning account (#847, #867) — fixed in 8206893** — `P2` — `bugfix-parity`
- What WEB does: parses/fetches the ICS through the message's source client and owner account, including directly viewed shared folders (`components/email/calendar-invitation-banner.tsx:373-388`, `412-432`; changelog 1.9.0).
- What RN does: `parseCalendarBlob(attachment.blobId)` uses `jmapClient.accountId` (`src/components/email/CalendarInvitationBanner.tsx:54`, `src/api/calendar.ts:355-360`) even though `EmailPane` knows `jmapAccountId`; Stalwart answers `CalendarEvent/parse` with an error for a blob in another account, so invitations in group/shared mailboxes show nothing (state `error` → banner hidden).
- Fix hint: pass `jmapAccountId` from `EmailPane` into the banner and through to `parseCalendarBlob(blobId, accountId)`.
- [x] **iMIP method detection ignores the raw ICS `METHOD` and Content-Type params — fixed in 8206893** — `P2` — `partial`
- What WEB does: reads `method=` from the part/attachment/header Content-Type, then fetches the raw ICS and reads `METHOD:` (`lib/calendar-invitation.ts:319-339`, `components/email/calendar-invitation-banner.tsx:419-447`), falling back to participant heuristics; `CANCEL`, `REPLY`, `COUNTER`, `REFRESH`, `DECLINECOUNTER`, `PUBLISH`, `ADD` each get their own title/info/actions.
- What RN does: `extractMethodFromRawIcs` exists (`src/lib/calendar-invitation.ts:103-106`) but is never called; only `inferInvitationMethod` heuristics are used (`CalendarInvitationBanner.tsx:62`), so a cancellation whose event status is not `cancelled`, or a REPLY from a single attendee, is shown as a plain invitation with RSVP buttons.
- Fix hint: fetch the blob text (`getDownloadUrl` + `secureFetch`, as `fetchRawEmail` does) in parallel with parse and prefer `extractMethodFromRawIcs`.
- [ ] **Banner lacks WEB's trust assessment, actor summary, existing-event / "already in calendar" state, calendar picker, "View in calendar", collapse, sequence badge, counter-proposal review** — `P3` — `partial` — deferred: trust assessment, iTIP method and calendar picker landed in 8206893 (calendar agent); existing-event state, "View in calendar", collapse, sequence badge and counter-proposal review remain open in the calendar area
- What WEB does: `getInvitationTrustAssessment` (sender vs organizer + auth results) warning, actor line ("X accepted"), `queryCalendarEvents({uid})` to show "already in calendar" and current RSVP, picker when >1 calendar, "View in calendar" navigation, collapsible card, `sequence` "updated" pill, apply counter proposal for organizers (`components/email/calendar-invitation-banner.tsx:392-474`, `502-553`, `657-672`, `721-752`, `802-1140`).
- What RN does: title/date/location/video/organizer rows, Yes/Maybe/No, Add to first writable calendar (`src/components/email/CalendarInvitationBanner.tsx:83-207`); no dedupe check before offering "Add", no picker, no trust warning, no navigation to the event.
- Fix hint: port `getInvitationTrustAssessment` (needs the headers finding for auth), look up `useCalendarStore.events` by `uid` to switch to "already in calendar"/current response, add a calendar picker sheet when `calendars.length > 1`, and a "View in calendar" button that navigates to `CalendarScreen` with the start date.
- [x] **The `.ics` part stays in the attachment list while the banner is shown — fixed in d2ed27f** — `P3` — `bugfix-parity`
- What WEB does: hides calendar MIME parts when the banner renders (`components/email/email-viewer.tsx:1591-1600`; changelog 1.4.14).
- What RN does: `renderAttachments` filters only inline images (`src/screens/EmailThreadScreen.tsx:713-722`).
- Fix hint: filter `isCalendarType(att.type) || name.endsWith('.ics')` when `calendarInvitationParsingEnabled && findCalendarAttachment(email)`.
### Attachments
- [x] **Forwarding drops every attachment — fixed in 7f956d6** — `P1` — `bug`
- What WEB does: forward pre-populates the composer with the original's attachments by blobId (minus embedded cid images) (`components/email/email-composer.tsx:598-616`, #543).
- What RN does: `navigateCompose('forward')` passes only from/to/cc/subject/plain body (`src/screens/EmailThreadScreen.tsx:366-382`); the `Compose.replyTo` param has no `attachments` field (`src/navigation/types.ts:7-19`) and `ComposeScreen` never seeds `attachments` from the original (`src/screens/ComposeScreen.tsx:354`). A forwarded invoice arrives without the PDF.
- Fix hint: add `attachments?: Attachment[]` to the route param, and in `ComposeScreen` initialise `attachments` with `{blobId,name,type,size}` entries (blobs are account-scoped so no re-upload is needed; for shared-folder messages the blob belongs to the owner account — either re-upload via `getDownloadUrl(..., ownerAccountId)` + `uploadBytes`, or block forward there).
- [x] **message/rfc822 attachments: no inline unwrap, no `.eml` preview — fixed in d2ed27f** — `P2` — `missing`
- What WEB does: when the outer body is empty (Outlook "forward as attachment") it parses the embedded message with postal-mime and renders its body + attachments, keeping the `.eml` chip; `.eml` attachments preview like an email (`components/email/email-viewer.tsx:1433-1507`, `1625-1636`; changelog 1.7.3, 1.8.1).
- What RN does: the message renders "(empty message)" and the `.eml` chip can only be shared.
- Fix hint: add `postal-mime` (pure JS, works in RN with a `TextDecoder` polyfill) and mirror the unwrap effect; render the parsed HTML through `EmailBodyView`.
- [x] **TNEF `winmail.dat` is not decoded — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: parses winmail.dat (body + embedded attachments) and hides the container (`lib/tnef.ts`, `components/email/email-viewer.tsx:1344-1431`).
- What RN does: shows `winmail.dat` as an opaque attachment; the real files inside are unreachable.
- Fix hint: `lib/tnef.ts` is pure `Uint8Array` code; copy it and feed `jmapClient.fetchBlobArrayBuffer`.
- [x] **Attachment chip list: no MIME-based fallback names, MDN/DSN report parts and non-inline cid images mishandled — fixed in d2ed27f** — `P3` — `partial`
- What WEB does: unnamed parts get `Document.pdf`/`Email.eml`/`Attachment.<sub>` (`components/email/email-viewer.tsx:191-225`); `message/disposition-notification` and `message/delivery-status` parts are hidden (`1604-1606`); inline hiding requires `disposition === 'inline'` (`1601-1603`).
- What RN does: unnamed → `'attachment'` (`src/screens/EmailThreadScreen.tsx:745`); report parts are listed; any `cid` image is hidden regardless of disposition (`719-721`), so an image attached with a Content-ID but `disposition: attachment` (common from some clients) disappears from the list even when the body does not reference it.
- Fix hint: port `getAttachmentDisplayName`, add the two report-type filters, and check `disposition === 'inline'` (or that the body actually references the cid via `extractCidRefs`).
- [x] **No image thumbnails on chips, no "Download all" zip, no per-chip Preview vs Download choice — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: image chips show the actual image (≤10 MB), a "Download all" bundles into a zip named by template, hover reveals separate Download/Preview buttons (`components/email/email-viewer.tsx:2024-2102`, `2104-2161`, `3949-4085`; changelog 1.6.0, 1.7.5).
- What RN does: single tap follows the global `mailAttachmentAction`; `jszip` is already a dependency (`package.json:36`) but unused for mail.
- Fix hint: long-press sheet with "Open / Save / Share"; a "Download all" entry building the zip with `jszip` into `Paths.cache` then sharing.
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f** — `P3` — `partial`
- What WEB does: `FilePreviewModal` for images/PDF (bytes handed to pdf.js, #871)/text/markdown/audio/video/eml, script-bearing types forced to download (`components/mail/mail-app.tsx:2887-2935`, `lib/file-preview.ts:29-92`).
- What RN does: Android hands the file to an external viewer via `ACTION_VIEW`, iOS opens the share sheet (`src/lib/email-export.ts:64-79`, `115-141`); nothing is previewed inside the app.
- Fix hint: for images/PDF/text open a modal `WebView` on the cached `file://` (or use `expo-file-system`'s `File.uri` with a QuickLook bridge on iOS); keep the external-viewer path as fallback.
- [x] **Post-export action (archive/trash after "Export .eml") missing — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: `postExportAction` setting runs archive/trash after export (`components/email/email-viewer.tsx:2559-2571`).
- What RN does: export only (`src/screens/EmailThreadScreen.tsx:632-640`).
- Fix hint: add the setting and call `onArchive`/`onDelete` after `shareEmailEml` resolves.
### Actions from the viewer
- [x] **`In-Reply-To`/`References` are written with the JMAP email id (#234 bugfix-parity) — fixed in 7f956d6** — `P1` — `bug`
- What WEB does: passes the original's RFC `messageId` and `references`, stripped of brackets, as JMAP `inReplyTo`/`references` arrays (`lib/jmap/client.ts:3124-3194`; changelog 1.5.4 #234).
- What RN does: `navigateCompose` sets `inReplyTo: email.id` (`src/screens/EmailThreadScreen.tsx:379`), and `sendEmail` writes it verbatim into `header:In-Reply-To:asText` / `header:References:asText` (`src/api/email.ts:823-826`). The outgoing header is a bare Stalwart object id, not a msg-id, so recipients' clients cannot thread the reply and `References` is never accumulated. `messageId`/`references` are not even fetched (see headers finding).
- Fix hint: fetch `messageId`/`references`, pass `inReplyTo: email.messageId?.[0]` and `references: [...(email.references ?? []), email.messageId[0]]`, and send them as JMAP `inReplyTo`/`references` arrays (or `<…>`-wrapped `header:…:asMessageIds`).
- [x] **Reply addressing: Reply-To ignored, own addresses kept on reply-all, self-sent thread replies (#703), external Reply-To on self-sent (1.9.0) — fixed in 7f956d6** — `P1` — `bugfix-parity`
- What WEB does: `buildReplyRecipients` — reply goes to `Reply-To` if present else `From`; reply-all adds original To/CC minus the user's own identities (exact and `+tag`-stripped); replying to your own message continues to its original recipients; an external Reply-To on a self-sent message still wins (`lib/reply-recipients.ts:74-111`, `components/mail/mail-app.tsx:920`, `2940-2944`).
- What RN does: `initialTo` = From (+ all original To on reply-all, including the user's own address), `initialCc` = all CC; `email.replyTo` is fetched but never passed (`src/screens/ComposeScreen.tsx:299-319`, `src/screens/EmailThreadScreen.tsx:366-382`). Replying to a newsletter with `Reply-To: support@…` mails the no-reply sender; reply-all sends the user a copy; replying to your own sent message addresses yourself.
- Fix hint: copy `lib/reply-recipients.ts` (pure), pass `replyToAddresses: email.replyTo` in the route param and the identities' emails as `ownEmails`.
- [x] **Reply/forward quote is plain text only — HTML formatting and inline images lost (#163, #543) — fixed in 7f956d6** — `P2` — `bugfix-parity`
- What WEB does: quotes the sanitized HTML body (with cid images re-attached inline) inside an editable quote island, localized "On … wrote:" header with `Name <email>` (`components/email/email-composer.tsx:462-500`, `lib/quote-header.ts:54-91`; changelog 1.7.1, 1.7.3, 1.8.1).
- What RN does: `buildInitialHtml` supports `htmlBody` (`src/lib/compose-html.ts:79-83`) but the viewer only passes `body: plainTextBody(email)` (`src/screens/EmailThreadScreen.tsx:377`), and the header uses display name only (`compose-html.ts:31-35`) rather than WEB's `Name <email>` (#482).
- Fix hint: pass `htmlBody` (sanitized via `stripDangerousTags`, cid refs rewritten to fetched data URIs or re-attached with the same `cid`) and switch `senderName` to the `Name <email>` form.
- [x] **Forward as attachment (.eml) missing — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: More menu "Forward as attachment" builds a `message/rfc822` attachment referencing the original blobId with a `{date}-{subject}.eml` name (`lib/forward-as-attachment.ts:38-58`, `components/mail/mail-app.tsx:2022-2060`; changelog 1.8.1).
- What RN does: none.
- Fix hint: add a More-sheet entry that opens `Compose` with `attachments: [{blobId: email.blobId, name, type: 'message/rfc822', size: email.size}]` (depends on the forward-attachments fix).
- [x] **Reply identity selection: exact match only, no `+tag` or catch-all domain override (#246) — fixed in 5b72c4d** — `P3` — `partial`
- What WEB does: `resolveReplyFrom` — exact, then `+tag`-stripped, then same-domain catch-all with a From override (`lib/reply-identity.ts:197-256`).
- What RN does: `identities.find(i => i.email === candidate.email)` (`src/screens/ComposeScreen.tsx:431-437`).
- Fix hint: port `resolveReplyFrom`; the override needs a From override in `sendEmail`.
- [x] **Quick reply box under the message — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: inline textarea with Send / "More options" (opens composer prefilled), Ctrl+Enter (`components/email/email-viewer.tsx:5066-5154`).
- What RN does: none.
- Fix hint: optional; a bottom input that calls `sendEmail` with `buildReplyRecipients` output and the plain-text quote.
- [x] **View source: no copy-to-clipboard; whole raw message loaded into a `<Text>` — fixed in d2ed27f** — `P3` — `partial`
- What WEB does: synthetic source from the JMAP object with a Copy button (`components/email/email-viewer.tsx:5159-5208`, `lib/email-source.ts`). RN's raw RFC 822 view is actually more faithful.
- What RN does: fetches the full blob as text and renders it in one selectable `Text` (`src/screens/EmailSourceScreen.tsx:24-35`, `68-70`); a 30 MB message will stall the JS thread.
- Fix hint: add `Clipboard.setStringAsync`; cap display at e.g. 1 MB with a "Share full source" fallback.
- [x] **Add sender to contacts / edit contact from the viewer — fixed in d2ed27f** — `P3` — `missing`
- What WEB does: contact sidebar with "Add to contacts"/"Edit" (`components/email/email-viewer.tsx:552-568`, `5336-5368`; changelog 1.7.4).
- What RN does: none (see popover finding).
- Fix hint: sheet entry navigating to `ContactForm` with prefilled name/email.
### Trusted senders / settings
- [x] **"Trust sender" writes to the Trusted Senders address book even when the setting is off — fixed in 06742ef** — `P3` — `rn-only-bug`
- What WEB does: writes to the book only when `trustedSendersAddressBook` is on, else to the local list (`components/email/email-viewer.tsx:4646-4655`, `components/trusted-senders-modal.tsx:136-140`).
- What RN does: always does both (`src/components/EmailBodyView.tsx:644-653`), and `addToTrustedSendersBook` creates the book on first add (`src/stores/contacts-store.ts:308-321`), so a user who left the setting off (default `false`, `src/stores/settings-store.ts:239`) still gets a server-side "Trusted Senders" address book; the settings modal then lists only the local entries (`src/components/settings/ContentSendersSettings.tsx:19-21`, `123-144`), so book-trusted senders are invisible/unremovable there.
- Fix hint: gate the book write on the setting (or, matching WEB 1.9.0, default the setting to on for contacts-capable accounts) and show `trustedSenderEmails` in the settings modal with remove support.
- [x] **S/MIME settings screen is a dead stub — fixed in 3d0d440** — `P3` — `rn-only-bug`
- What WEB does: built-in S/MIME was removed from core in 1.7.6 and lives in a privileged plugin; the viewer has no S/MIME state (`components/email/email-viewer.tsx:923-937`).
- What RN does: `SmimeSettings` renders empty mock arrays and buttons with no handlers (`src/components/settings/SmimeSettings.tsx:32-33`, `118-121`, `163-166`); there is no verification/decryption in the viewer.
- Fix hint: hide the screen (or show a "not available in the mobile app" note) until a crypto path exists; treat viewer-side S/MIME as N/A.
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Body isolation: sandboxed WebView with `default-src 'none'` CSP, `originWhitelist` about:blank, links opened externally, no cookies/storage/file access (`src/components/EmailBodyView.tsx:675-724`, `src/lib/email-html.ts:236-256`) — matches WEB's srcDoc iframe + CSP approach (1.6.7).
- `hasMeaningfulHtmlBody` text-alternative preference (`src/lib/email-html.ts:353-362`) — same regex as WEB `lib/signature-utils.ts` (only the same-partId guard is missing, see P1 finding).
- Dark mode: invert+hue-rotate with media re-invert, native-dark-mode detection, emoji re-invert pass, "Always view emails in light mode" setting (`src/lib/email-html.ts:167-185`, `197-199`, `src/components/EmailBodyView.tsx:64-157`, `499-502`) — only the CSS revision differs (see finding).
- Plain-text path: escaped + linkified, native dark colours without inversion (`src/lib/email-html.ts:120-126`, `263-311`).
- External content policy ask/block/allow, per-message "Load external content", "Trust sender" persisting locally and to the address book, trusted list from the address book and contacts (`src/components/EmailBodyView.tsx:480-535`, `643-672`).
- cid inline images fetched with auth and injected as data URIs, transparent placeholder otherwise (`src/components/EmailBodyView.tsx:546-584`, `src/lib/email-html.ts:213-218`).
- Shrink-to-fit of fixed-width emails + in-page pinch zoom/pan with pager/scroll locking (`src/components/EmailBodyView.tsx:159-460`) — RN-only, better than WEB's horizontal scroll (#409).
- Mark read on open with delay setting (apart from the `-1` bug), optimistic star/unread/tag toggles, delete/archive/spam/not-spam/move scoped to the message's account, `no trash folder` alert (`src/screens/EmailThreadScreen.tsx:221-364`).
- Prev/next navigation with prefetched neighbour panes and swipe (`src/screens/EmailThreadScreen.tsx:122-181`, `535-572`) — RN-only pager.
- Skeleton while loading (`src/screens/EmailThreadScreen.tsx:845-886`).
- Attachments: chips with size, show-all toggle, `hideInlineImageAttachments` setting, `attachmentPosition` setting, `mailAttachmentAction` preview/download, templated filenames + transforms identical to WEB (`src/lib/download-filename.ts` diff vs `lib/download-filename.ts` is whitespace/quotes only, minus `DEFAULT_BUNDLE_TEMPLATE`/`BUNDLE_TOKENS`), Downloads settings screen with live preview (`src/components/settings/DownloadsSettings.tsx`).
- Attachment/blob/source/.eml fetches routed by owner account for shared/group messages (`src/screens/EmailThreadScreen.tsx:87-96`, `198-215`, `src/api/blob.ts:119-135`, `src/lib/email-export.ts`) — #847/#867 parity except the calendar banner.
- Client-cert-aware downloads via `secureFetch`, streaming download via `File.downloadFileAsync` (`src/lib/email-export.ts:85-113`).
- View source (raw RFC 822, shareable) and Export .eml with the email filename template (`src/screens/EmailSourceScreen.tsx`, `src/lib/email-export.ts:173-206`).
- Calendar invitation: detection by MIME/extension, parse via `CalendarEvent/parse`, RSVP via import-then-`rsvpEvent` with `replyTo`/`organizerCalendarAddress`, cancelled notice, "no writable calendar" warning, `calendarInvitationParsingEnabled` setting (`src/components/email/CalendarInvitationBanner.tsx`, `src/lib/calendar-invitation.ts`).
- Tag sheet from the viewer with keyword definitions (`src/screens/EmailThreadScreen.tsx:1033-1100`).
## N/A on mobile
- Print (WEB `handlePrint`, `components/email/email-viewer.tsx:2621-2670`) — could be done with `expo-print` later, but not a parity requirement.
- Copy permalink / deep link to a message (#733) — RN has no linking configuration; no `linking` prop in `App.tsx`.
- Fullscreen reading toggle, Pro split panes, toolbar overflow measurement, plugin slots/hooks (`email-banner`, `email-detail-sidebar`, `onRenderEmailBody`, `onBeforeExternalLink`, `ui.rerenderEmail`), keyboard shortcuts, drag attachments out to the file system (#267), drag emails out as `.eml`, import `.eml` from the viewer menu (RN imports from the list screen instead).
- Iframe-specific fixes: "Render the email body on DOM parse instead of iframe load" (#635), iframe flash/flicker fixes (1.5.4, 1.6.7), `blob:` in CSP `object-src`/`frame-src` for PDF previews (#253), pdf.js bytes vs `blob:` fetch (#871), RSC 307 loop (#919), iOS Safari viewport zoom on input focus (#838).
- Demo-mode welcome pane, tour, contact sidebar panel layout (desktop only; the actions themselves are listed above).
- S/MIME plugin UI (WEB core has none since 1.7.6); listed above only because RN ships a misleading stub.
+313
View File
@@ -0,0 +1,313 @@
# Composer, drafts, sending, identities, signatures, templates, scheduled send
## Summary
RN has a working "compose / reply / forward / send now / send later" happy path (WebView rich editor with send-time readback, attachments + inline images, contact suggestions, identity sheet, HOLDFOR scheduling with a cancel list) but is far from parity with the 3,900-line web composer. The biggest gaps are structural: RN has **no drafts at all** (no autosave, no save-on-close, no opening/editing an existing draft), **no signatures** (identities carry `textSignature`/`htmlSignature` but the composer never uses them), **no Bcc**, **no template insertion in the composer**, **no Reply-To handling**, and **broken threading headers** (`In-Reply-To` is set to the JMAP email id instead of the RFC Message-ID). The send path also files the message into Sent *before* submission, so a failed `EmailSubmission/set` leaves a never-sent copy in Sent. Several web bugfixes (reply-all self-dedupe / #703, #570 attachment reminder on quoted text, #702 request timeout, unclosed angle brackets, subject-prefix dedupe) are not present in RN.
Legend for refs: WEB paths are relative to `the webmail repo`, RN paths to `the native repo`.
## Findings
### Drafts
- [x] **No draft autosave / save-on-close — closing a compose loses everything** — fixed in 5b72c4d — `P1` — `missing`
- What WEB does: debounced 2 s autosave to the server Drafts folder with `$draft`+`$seen` keywords (`components/email/email-composer.tsx:1648-1815` saveDraftOnce, `:1842-1883` timer; `lib/jmap/client.ts:2998-3060` createDraft), serialized against send (#303, `:1821-1837`), `beforeunload` best-effort save (`:1082-1091`), "Save / Discard / Cancel" close dialog (`:2347-2426`, `handleSaveDraftAndClose`, `handleDiscardAndClose`), toast when the save is refused so text is not dropped (`:2384-2401`, #702).
- What RN does: the only close path is `onClose` in `src/screens/ComposeScreen.tsx:518-541`, an Alert with Cancel / **Discard** — no "Save draft" option and no server draft is ever created. The hardware back button / iOS swipe-back is not intercepted at all (no `beforeRemove` listener anywhere in `ComposeScreen.tsx` or `src/navigation/*`), so the dirty guard is bypassed entirely by the OS back gesture.
- Fix hint: add a `createDraft(...)`-style call in `src/api/email.ts` (Email/set create into the `drafts`-role mailbox with `$draft`/`$seen`, destroy previous draft id only after the new create succeeds), call it from a debounced effect + from the close Alert ("Save draft" button), and register `navigation.addListener('beforeRemove', …)` to route back gestures through the same guard.
- [x] **Cannot open / edit an existing draft** — fixed in 5b72c4d — `P1` — `missing`
- What WEB does: `handleEditDraft` in `components/mail/mail-app.tsx:1812` re-opens a draft with recipients, subject, HTML body, identity restored from `From` (`lib/reply-identity.ts:140-167` findDraftIdentityId), server attachments carried as blobId refs (`email-composer.tsx:577-597`, #849) and the signature embedded (`:520-535`, #848); sending destroys the old draft only after success (`lib/jmap/client.ts:3342-3360`) and clears the viewer when the sent draft was displayed (`mail-app.tsx:1645-1648`).
- What RN does: `RootStackParamList.Compose` (`src/navigation/types.ts:7-22`) has no `draftId`/initial-body params; tapping a message in the Drafts folder opens the read-only `EmailThreadScreen` (no `role === 'drafts'` handling anywhere in `src/screens/EmailThreadScreen.tsx` or `src/screens/EmailListScreen.tsx`).
- Fix hint: add a `draft` route param (id, to/cc/bcc, subject, htmlBody, attachments, from), an "Edit draft" action in the thread screen when the mailbox role is `drafts` or the email has `$draft`, and pass `draftId` into `sendEmail` so the old draft is destroyed after a successful submission. Depends on the previous item.
- [x] **Draft keeps In-Reply-To/References and reply-mode state** — fixed in 5b72c4d — `P2` — `missing`
- What WEB does: drafts store the reply context in `ComposerDraftData` (`email-composer.tsx:109-132`) and threading headers are computed at send from the parent (`lib/email-threading.ts:30-51`).
- What RN does: N/A today because drafts do not exist; when implementing drafts make sure `inReplyTo`/`references` (see the threading finding below) survive the round trip.
- Fix hint: persist `header:In-Reply-To`/`header:References` on the draft Email/set and read them back via `inReplyTo`/`references` properties when reopening.
### Sending / EmailSubmission
- [x] **Message is filed into Sent before submission — a failed send leaves a fake "sent" copy** — fixed in ccbe67c — `P1` — `bug`
- What WEB does: creates the email in Drafts with `$draft` and moves it to Sent via `onSuccessUpdateEmail` on the `EmailSubmission/set` (`lib/jmap/client.ts:3195-3196`, `:3234-3239`, #188); a `notCreated` on the submission throws and the draft simply stays in Drafts (`:3289-3312`).
- What RN does: `src/api/email.ts:794-796` sets `mailboxIds: { [sentMailboxId]: true }, keywords: { $seen: true }` on the `Email/set` create, and the `EmailSubmission/set` at `:848-855` has no `onSuccessUpdateEmail`. If the submission is rejected (`notCreated` handled at `:870-872`) the user gets a "Send failed" alert but the message is already sitting in Sent looking sent; a retry duplicates it.
- Fix hint: create into the Drafts mailbox with `$draft`, add `onSuccessUpdateEmail: { '#draft': { 'mailboxIds/<sent>': true, 'mailboxIds/<drafts>': null, 'keywords/$draft': null } }` to the submission call; RN already has `ownMailboxes(mailboxes)` to find the Drafts role.
- [x] **In-Reply-To / References carry the JMAP email id instead of the Message-ID** — fixed in 7f956d6 — `P1` — `rn-only-bug`
- What WEB does: `computeReplyThreadingHeaders` (`lib/email-threading.ts:30-51`) builds `inReplyTo = [parent.messageId]`, `references = parent.references + parent.messageId`, brackets stripped; the client sends them as the JMAP `inReplyTo`/`references` properties (`lib/jmap/client.ts:3177-3194`) and fetches `messageId`/`inReplyTo`/`references` on the email (`:1787-1788`).
- What RN does: `src/screens/EmailThreadScreen.tsx:379` passes `inReplyTo: email.id` (the JMAP id, e.g. `a1b2c3`), never `references`; `src/api/email.ts:823-826` writes that value verbatim into `header:In-Reply-To:asText` and `header:References:asText`. `EMAIL_FULL_PROPERTIES` (`src/api/email.ts:11-15`) does not even request `messageId`/`references`, and the RN `Email` type (`src/api/types.ts:65-87`) has no such fields. Every RN reply therefore breaks threading in recipients' clients and emits a syntactically invalid `In-Reply-To`.
- Fix hint: add `'messageId', 'inReplyTo', 'references'` to `EMAIL_FULL_PROPERTIES` and the `Email` type, port `computeReplyThreadingHeaders`, and send `inReplyTo`/`references` as JMAP array properties (bare msg-ids) instead of raw `header:*:asText` strings. Only replies should continue the chain; forwards should not (WEB `email-composer.tsx:2142-2145`).
- [x] **No request timeout — a stalled send hangs the composer forever** — fixed in ccbe67c — `P2` — `bugfix-parity` (changelog 1.7.x "Time out stalled JMAP requests so a send can't hang forever (#702)")
- What WEB does: `JMAPClient.REQUEST_TIMEOUT_MS = 30_000` (`lib/jmap/client.ts:629`, `:811`, `:830-843`) raising `RequestTimeoutError`; the composer shows a dedicated "may already have gone out, check Sent" message instead of "send failed" so users do not re-send duplicates (`email-composer.tsx:2315-2323`).
- What RN does: `jmapClient.request` (`src/api/jmap-client.ts:405-445`) uses `secureFetch` with no `AbortSignal`/deadline; `performSend` (`ComposeScreen.tsx:906-982`) leaves `sending=true` until the promise settles, so the Send button spins indefinitely on a stalled connection.
- Fix hint: wrap `secureFetch` in an `AbortController` + `setTimeout` (30 s) in `jmapClient.request` (or a `timeoutMs` option used by `sendEmail`), throw a distinguishable `RequestTimeoutError`, and show a "may already be sent - check Sent" alert in `performSend`'s catch.
- [x] **Undo-send delay has no undo toast / "send now"** — fixed in 5b72c4d — `P2` — `partial`
- What WEB does: after a delayed send `pendingUndoSend` is set (`stores/email-store.ts:1700-1712`) and a toast offers Undo / Send now (changelog 1.7.0 "'Send now' action on the send-delay toast"; `email-store.ts:4381` cancelUndoSend).
- What RN does: `onSend` (`ComposeScreen.tsx:825-828`) applies `sendDelaySeconds` as HOLDFOR and `performSend` deliberately stays silent for the undo window (`:961-963` comment: "invisible unless the user cancels from the Scheduled view"). There is an `UndoSnackbar` component (`src/components/UndoSnackbar.tsx`) but it is not used for send. The user has to know to open the Scheduled screen within the 5-30 s window.
- Fix hint: after `sendEmail` returns `{ scheduled: true, emailSubmissionId }` show `UndoSnackbar` for `sendDelaySeconds` with Undo → `cancelScheduledSend(id)` and "Send now" → EmailSubmission/set update of the envelope without HOLDFOR (or cancel + resend), mirroring `rescheduleEmailSubmission` in `lib/jmap/client.ts:7985`.
- [x] **Double-submit guard relies on async state** — fixed in 7f956d6 — `P3` — `bugfix-parity` (changelog 1.6.x "Guard Send against double-submit")
- What WEB does: synchronous `isSendingRef` re-entry guard (`email-composer.tsx:1980-1986`, `:2007`, `:2078-2079`).
- What RN does: `performSend` checks `canSend` which includes `!sending` (`ComposeScreen.tsx:508-516`, `:883`), but `setSending(true)` only happens after two awaits (`getHtml` at `:892`, attachment reminder at `:905`), so two quick taps both pass the guard and submit twice.
- Fix hint: add a `sendingRef = useRef(false)` set synchronously at the top of `performSend`, reset in `finally`.
- [x] **Reply / forward does not mark the original `$answered` / `$forwarded`** — fixed in 7f956d6 — `P2` — `missing`
- What WEB does: after a successful reply/forward the original gets `$answered` or `$forwarded` (`components/mail/mail-app.tsx:1663-1673`), routed to the owning account.
- What RN does: nothing — `grep -F '$answered'` over `src/` returns no hits; `performSend` (`ComposeScreen.tsx:941-974`) has no access to the original email id (`replyTo` params in `src/navigation/types.ts:10-19` carry no `emailId`).
- Fix hint: add `originalEmailId`/`jmapAccountId` to the `replyTo` route param and call the existing keyword primitive (`setEmailKeywords` via `applyOrQueue` in `src/stores/outbox-store.ts`) after the send resolves.
- [x] **Identity `replyTo` / `bcc` are never applied to outgoing mail** — fixed in 7f956d6 — `P2` — `missing`
- What WEB does: `sendEmail` copies the identity's `replyTo` onto the message (`lib/jmap/client.ts:3167-3172`, `:3184`); identity form lets users edit Reply-To and Bcc (`components/identity/identity-form.tsx:39-41`, `:66-70`, `:132-133`).
- What RN does: RN `Identity` type has `replyTo`/`bcc` (`src/api/types.ts:136-145`) but `sendEmail` (`src/api/email.ts:788-796`) never sets `replyTo`, and no composer Reply-To field exists; `IdentitySettings.tsx:209-215` edits only name/email/textSignature.
- Fix hint: in `sendEmail` set `replyTo: identity.replyTo` and merge `identity.bcc` into `bcc`; pass the full `Identity` (not only `primaryIdentity.id`) from `performSend`.
- [x] **Read-receipt (MDN) request missing** — fixed in 5b72c4d — `P3` — `missing`
- What WEB does: composer toggle (`email-composer.tsx:3053-3066`), `requestReadReceiptDefault` setting (`stores/settings-store.ts:351`), header `Disposition-Notification-To` set on create (`lib/jmap/client.ts:3201-3204`).
- What RN does: no toggle, no setting, header never set.
- Fix hint: add a toolbar toggle + `requestReadReceipt` option to `sendEmail` that writes `header:Disposition-Notification-To:asText`.
- [x] **Client-side Message-ID not generated** — fixed in ccbe67c — `P3` — `partial`
- What WEB does: `generateMessageId(fromEmail)` with the sender's domain, guarded for insecure origins (`lib/jmap/client.ts:544-575`, `:3192`; changelog 1.9.0 fix).
- What RN does: `sendEmail` sets no `messageId`, relying on the server; RN's `src/lib/uuid.ts:6-20` already has a guarded UUID generator (Hermes fallback) so the crash class of the web fix does not apply.
- Fix hint: optional — set `messageId: [`${generateUUID()}@${domain}`]` in `sendEmail` so the id is known before the response (useful once drafts/threading exist).
- [ ] **Sending from shared/group accounts unsupported** — `P3` — `missing` (partly N/A) — deferred: RN is single-active-account; envelope/identity routing for shared accounts not implemented
- What WEB does: routes the submission to the identity's owning account (#461, `email-composer.tsx:2258-2274`, `lib/jmap/client.ts:3131-3134` targetAccountId) and preselects the shared folder's identity (`lib/reply-identity.ts:114-128`).
- What RN does: `ComposeScreen.tsx:267-272` explicitly always uses the user's own Sent ("composing on behalf of a shared account isn't supported"), `getIdentities` (`src/api/identity.ts:5-12`) only queries the primary account. Replying from a shared-folder message sends as the reaching login.
- Fix hint: low priority; if implemented, query `Identity/get` for the shared `accountId`, submit with that `accountId`, and file into that account's Sent.
- [x] **Dead duplicate send implementation** — fixed in 42ab246 — `P3` — `rn-only-bug`
- What RN does: `src/api/submission.ts:5-64` is an older `sendEmail` (no HOLDFOR, no result) that nothing imports (`grep "api/submission"` → no hits); `ComposeScreen.tsx:38` imports `sendEmail` from `src/api/email.ts`.
- Fix hint: delete `src/api/submission.ts` (and its test if any) to avoid someone wiring the stale path.
### Recipients
- [x] **No Bcc field** — fixed in 5b72c4d — `P2` — `missing`
- What WEB does: Bcc chip input toggled next to To (`email-composer.tsx:2768-2792`, `:2825-2851`), included in send/draft/dirty-check.
- What RN does: only To and Cc (`ComposeScreen.tsx:346-349` state, `:1058-1149` UI); `sendEmail` accepts `bcc` (`src/api/email.ts:772`) but the screen never passes it.
- Fix hint: clone the Cc block (`ccRecipients`/`ccInput`/`ccVisible`) as Bcc, include it in `alreadySelected`, `commitTyped`, `isDirty`, and the `sendEmail` call.
- [x] **Reply-To header ignored when replying** — fixed in 7f956d6 — `P2` — `bugfix-parity` (changelog 1.9.0 "Honour an external Reply-To even on a self-sent message"; 1.7.x #703)
- What WEB does: `buildReplyRecipients` (`lib/reply-recipients.ts:74-111`) replies to `replyToAddresses` when present (RFC 5322), falls back to From, and handles self-sent messages (`isSelfSent`, `:52-54`) by addressing the original To/Cc instead of yourself.
- What RN does: `initialTo` (`ComposeScreen.tsx:291-312`) always uses `replyTo.from`; `EmailThreadScreen.tsx:370-381` does not pass `email.replyTo` although it is fetched (`EMAIL_FULL_PROPERTIES` includes `replyTo`). Replying to a mailing-list/ticket mail with Reply-To goes to the wrong address; replying to your own sent message mails yourself.
- Fix hint: pass `replyToAddresses: email.replyTo` in the route param and port `buildReplyRecipients` (pure TS, no DOM) into `src/lib/`.
- [x] **Reply-all does not drop the user's own identities / duplicates** — fixed in 7f956d6 — `P2` — `bugfix-parity`
- What WEB does: reply-all filters every own identity address (exact and `+tag`-stripped) out of To/Cc (`lib/reply-recipients.ts:39-45`, `:104-110`) and `expandRecipients` dedupes case-insensitively across the whole list (`lib/email-composer-utils.ts:425-437`).
- What RN does: `initialTo` (`ComposeScreen.tsx:304-310`) adds every original `to` except the From; `initialCc` (`:314-319`) copies `cc` unfiltered — the user's own address lands in To/Cc of every reply-all, and the same address can appear in both To and Cc (`alreadySelected` at `:371-374` only guards suggestions).
- Fix hint: filter against the identities list (already loaded at `:406-419`, but note it loads asynchronously — compute recipients after identities arrive or filter at send time) and dedupe across To/Cc/Bcc in `commitTyped`.
- [x] **Cc addresses are not validated; `Name <addr` recovery and quoted names missing** — fixed in 5b72c4d — `P3` — `bugfix-parity` (changelog 1.9.0 "Split recipient lists whose angle brackets never close", 1.7.x #672)
- What WEB does: `splitRecipients` is quote/angle-aware and recovers from an unclosed `<` (`lib/email-composer-utils.ts:275-321`), `splitPastedRecipients` keeps display names, unwraps `<addr>` tokens and returns leftovers to the input (`:470-521`), `splitMailbox` tolerates a missing `>` (`lib/rfc5322-mailbox.ts:49-58`), `isValidEmail` is RFC 5322 with injection guards (`lib/validation.ts:4-28`).
- What RN does: `parseRecipients` (`ComposeScreen.tsx:69-84`) splits on `[,;\n]` regardless of quotes, requires a closed `>` (else the whole string becomes the "email"), and `hasValidRecipients` (`:504`) only checks **To** against a loose `EMAIL_RE` (`:66`) — an invalid Cc chip is sent to the server and fails there. `"Doe, John" <j@x>` becomes two chips.
- Fix hint: port `splitRecipients`/`parseRecipient`/`splitPastedRecipients` and `isValidEmail` (all DOM-free) into `src/lib/recipients.ts`; validate Cc/Bcc in `canSend` and mark invalid chips red.
- [x] **Contact groups not offered as recipients; no server (Sent-folder) search** — fixed in 5b72c4d — `P3` — `missing`
- What WEB does: groups appear in autocomplete as a single expandable chip (`email-composer.tsx:1214-1245`, changelog 1.7.0), plus an on-demand "search the server" row backed by Sent (`:1189-1212`, changelog 1.6.x).
- What RN does: suggestions come only from `individuals` (`ComposeScreen.tsx:367-392`, groups filtered out); groups are reachable only via `GroupDetailScreen.tsx:83` prefill.
- Fix hint: include groups in `suggestions` and expand members on pick (use `isGroup`/members from `contacts-store`); server search is optional.
- [x] **Recipient chips show no avatar / no "Name <addr>" tooltip; no chip editing** — fixed in 5b72c4d — `P3` — `partial` (N/A for drag-reorder)
- What WEB does: avatars in suggestions, chip context menu (copy, add to contacts, move To/Cc/Bcc) (`email-composer.tsx:3860-3895`).
- What RN does: `RecipientChip` (`ComposeScreen.tsx:111-124`) shows name-or-email with an X only.
- Fix hint: long-press menu with "Move to Cc/Bcc", "Copy address", "Add to contacts".
### Identity / From
- [x] **Sub-addressing (`user+tag@`) missing** — fixed in 5b72c4d — `P3` — `missing`
- What WEB does: `SubAddressHelper` next to From with tag suggestions (`components/identity/sub-address-helper.tsx`, `lib/sub-addressing.ts`), configurable delimiter (`stores/settings-store.ts:347`), identity matching strips `+tag` (`lib/reply-identity.ts:18-31`).
- What RN does: none; identity matching in `ComposeScreen.tsx:424-441` is exact-address only.
- Fix hint: port `lib/sub-addressing.ts` (pure) and add a small "+tag" affordance in the From row.
- [x] **From override / catch-all alias on an owned domain missing** — fixed in 5b72c4d — `P3` — `missing` (changelog 1.5.x #246)
- What WEB does: `resolveReplyFrom` (`lib/reply-identity.ts:197-256`) picks an identity or, when the message arrived at an unknown address on a domain the user owns, pre-fills a From override sent through the identity's envelope (`email-composer.tsx:2598-2708` UI, `:2113-2121` envelope).
- What RN does: From is always an identity (`ComposeScreen.tsx:908`).
- Fix hint: port `resolveReplyFrom`; allow an override name/email pair and set `submissionCreate.envelope.mailFrom` to the identity (RN already builds envelopes for HOLDFOR at `src/api/email.ts:828-844`).
- [x] **Identity preselection does not consider Bcc or `+tag` and never uses the primary-identity setting** — fixed in c659b54 — `P3` — `partial`
- What WEB does: exact → base (`+tag` stripped) match over to/cc/bcc (`lib/reply-identity.ts:38-68`), self-sent keeps the sending identity (`email-composer.tsx:812-822`), synced `preferredPrimaryId` default identity (#507, `stores/identity-store.ts:18,77`; `hooks/use-identity-sync.ts`).
- What RN does: `ComposeScreen.tsx:424-441` matches exact addresses in to/cc only (no bcc, no base match), default = identity matching the active account email else `identities[0]`; no "default identity" setting exists in `IdentitySettings.tsx`.
- Fix hint: port `findReplyIdentityId`; add a "Use as default" action in `IdentitySettings` persisted in `settings-store`.
- [x] **Identity display name not sanitized on create/update** — fixed in ccbe67c — `P3` — `bugfix-parity` (changelog 1.5.x "Sanitize identity display name to prevent invalid From headers")
- What WEB does: `sanitizeIdentityDisplayName` before every Email/set (`lib/jmap/client.ts:3034`, `:3181`) and `sanitizeDisplayName` (`lib/rfc5322-mailbox.ts:27-30`).
- What RN does: `IdentitySettings.tsx:253-278` sends the raw name; `performSend` uses `primaryIdentity.name` verbatim (`ComposeScreen.tsx:908`). A name like `Jane <jane@x>` or one containing a newline produces a malformed From.
- Fix hint: port `sanitizeDisplayName` and apply it in `saveDraft` (IdentitySettings) and in `sendEmail`'s `from`.
- [x] **Identity editor lacks HTML signature, Reply-To, Bcc fields and size caps** — fixed in c659b54 — `P3` — `partial` — note: the HTML signature is edited as raw HTML in a monospace field (no rich preview); links/images are sanitized on save
- What WEB does: `components/identity/identity-form.tsx` edits name, Reply-To list, Bcc list, text + HTML signature with a 2047-byte cap hint (`:12`, `:39-41`, `:66-70`, `:126-133`), sanitizes HTML.
- What RN does: `IdentitySettings.tsx:209-215` DraftIdentity has name/email/textSignature only.
- Fix hint: add multiline `htmlSignature` (or keep text-only but at least Reply-To/Bcc comma lists validated with the ported `isValidEmail`).
- [ ] **Identity sync (visibility/interval refresh) missing** — `P3` — `partial` — deferred: composer refetches on every mount; acceptable per finding
- What WEB does: `hooks/use-identity-sync.ts` refreshes identities on tab focus and every 30 min.
- What RN does: `ComposeScreen.tsx:406-419` refetches on every composer mount (adequate), but `settings-store.identities` (`src/stores/settings-store.ts:424-431`) only refreshes when the Identities settings pane mounts. Acceptable; listed for completeness.
- Fix hint: optional `AppState` foreground refresh.
### Signatures
- [x] **Signatures are never inserted or sent** — fixed in 5b72c4d — `P2` — `missing`
- What WEB does: compose embeds the signature as an editable block (`email-composer.tsx:254-275` buildEmbeddedSignatureHtml, `:437-443`), replies place it above or below the quote per `signaturePosition` (`:345-353`, `stores/settings-store.ts:349-350`), `-- ` separator toggle, plain-text fallback (`lib/signature-utils.ts:18-51`), alias without signature falls back to the primary identity's (`email-composer.tsx:688-693`), identity switch swaps the block (`:702-775`), double-click unlocks the atom for editing (`components/email/signature-block.ts:112-120`, `:240`), drafts keep it (#848).
- What RN does: `Identity.textSignature`/`htmlSignature` exist (`src/api/types.ts:142-143`) and are editable in settings, but `buildInitialHtml` (`src/lib/compose-html.ts:64-106`) and `performSend` (`ComposeScreen.tsx:906-960`) never reference them. Users who set a signature in RN or on the web get none on mobile.
- Fix hint: in `buildInitialHtml` append `<p>-- </p><div data-signature>…</div>` (sanitized `htmlSignature`, else escaped `textSignature`) after the empty paragraph for compose and above/below the quote for replies according to a new `signaturePosition` setting; re-splice on identity change via `editorRef.setHtml`. The WebView editor is a plain contenteditable, so no "unlock" mechanism is needed (mobile equivalent of 3d1bdf09 is simply "always editable").
- [x] **Signature settings (position, separator) absent** — fixed in c659b54 — `P3` — `missing`
- What WEB does: `components/settings/composing-settings.tsx:84-96` (position above/below quote, `-- ` separator).
- What RN does: `ComposingSettings.tsx:13-20` exposes auto-select identity, send delay, attachment reminder only; `settings-store.ts:131-137` has no signature keys.
- Fix hint: add `signaturePosition` / `signatureSeparatorEnabled` to `settings-store` and the Composing pane (do this together with the signature finding).
### Editor / body
- [x] **Reply / forward quotes plain text only — HTML layout and inline images are lost** — fixed in 5b72c4d — `P2` — `missing` (changelog #163, #543, "Editable, layout-preserving quote island")
- What WEB does: `getQuoteBodies` picks the right part by MIME (`lib/email-composer-utils.ts:36-55`, #649), quotes the sanitized HTML as an editable island (`email-composer.tsx:482-496`), rewrites `cid:` images and hydrates them as data URLs so they render and are re-attached on send (`:873-957`, `:1938-1978`).
- What RN does: `EmailThreadScreen.tsx:59-65` `plainTextBody` returns the text part or `preview`, and `navigateCompose` passes only `body` (`:377`); `buildInitialHtml` supports `htmlBody` (`compose-html.ts:76-80`) but is never given one. Replies to HTML-only mail quote whatever `textBody[0]` holds (for HTML-only messages RFC 8621 exposes the HTML source there — raw tags in the quote, the #649 bug) and inline images vanish.
- Fix hint: pass `htmlBody` (sanitized via `stripDangerousTags`/`email-html.ts`) from the thread screen, port `getQuoteBodies`' MIME routing, and for `cid:` images fetch the blob (`getDownloadUrl` in `src/api/blob.ts:119`) as base64, insert with `data-cid`, and add the part as `disposition: 'inline'` on send (RN's `rewriteInlineImages` at `compose-html.ts:143-161` already handles `data-cid`).
- [x] **Forward drops the original attachments** — fixed in 7f956d6 — `P1` — `missing`
- What WEB does: forward seeds the composer with the original's attachments as blobId refs (`email-composer.tsx:598-617`), skipping cid-embedded images, and sends them along (`:2200-2213`); "Forward as attachment" also exists (`lib/forward-as-attachment.ts`).
- What RN does: `navigateCompose` (`EmailThreadScreen.tsx:366-382`) passes no attachments and `Compose` params have no attachment field; `attachments` state starts empty (`ComposeScreen.tsx:354`). A forwarded invoice arrives without the invoice, silently.
- Fix hint: add `attachments: email.attachments` (blobId/name/type/size, filter `disposition==='inline'` images referenced by the body) to the route param and seed `AttachmentEntry`s with `blobId` set and `uploading:false`; `OutgoingAttachment` already accepts blobIds from another message in the same account.
- [x] **Quote header is English-only and shows the sender name without address** — fixed in 5b72c4d — `P3` — `partial` (changelog #482, i18n quote header)
- What WEB does: localized `quote_header.*` labels and `On {date}, {from} wrote:` (`lib/quote-header.ts:54-91`), sender rendered as `Name <email>`.
- What RN does: `compose-html.ts:84-105` hardcodes "Forwarded message", "From/Date/Subject", "On …, X wrote:" and `senderName` (`:30-33`) returns name **or** email, never both. Locale files exist under `locales/<lang>/`.
- Fix hint: pass translated labels via `QuoteHeaderOptions` and render `Name <email>` (escaped) like the web.
- [x] **Subject prefix handling does not strip foreign / stacked prefixes and is not localized** — fixed in 7f956d6 — `P3` — `bugfix-parity` (changelog "Drop single-letter R:/I: tokens and deduplicate localized reply/forward prefixes", "full-width colon")
- What WEB does: `lib/subject-prefix.ts:14-56` token lists + `buildReplySubject`/`buildForwardSubject` (`:109-121`) strip `Re: AW: WG: Fwd:` chains, `Re[2]:`, full-width colons, then add the locale prefix from `email_composer.prefix.*`.
- What RN does: `ComposeScreen.tsx:321-328` only tests `^re:`/`^fwd?:`; "AW: foo" becomes "Re: AW: foo", "Fwd: foo" replied becomes "Re: Fwd: foo".
- Fix hint: copy `lib/subject-prefix.ts` verbatim (pure TS) and use the locale's prefix strings.
- [x] **Attachment reminder scans the quoted original** — fixed in 5b72c4d — `P3` — `bugfix-parity` (changelog #570)
- What WEB does: `extractUserAuthoredText` strips blockquotes/quoted island and everything after the forwarded separator before keyword matching (`lib/email-composer-utils.ts:175-210`, used at `email-composer.tsx:2060-2066`).
- What RN does: `passesAttachmentReminder` (`ComposeScreen.tsx:764-791`) runs `htmlToPlainText` over the **whole** editor HTML including the `<blockquote>`/forward block built by `buildInitialHtml`, so replying to any mail that mentions "attached" fires the warning.
- Fix hint: strip `<blockquote>…</blockquote>` and everything from the forwarded separator before matching (regex is fine, no DOM needed).
- [x] **Empty subject blocks Send instead of confirming** — fixed in 5b72c4d — `P3` — `bugfix-parity` (changelog #684)
- What WEB does: confirm dialog with "Don't ask again" (`email-composer.tsx:2046-2051`, `:3228-3264`, setting `emptySubjectWarningEnabled`).
- What RN does: `canSend` requires `subject.trim().length > 0` (`ComposeScreen.tsx:513`) — the Send button is simply disabled with no explanation.
- Fix hint: drop the subject condition from `canSend` and add an Alert ("Send without subject?") gated by a new `emptySubjectWarningEnabled` setting.
- [x] **Toolbar coverage: no text color, tables, font, horizontal rule, text-direction** — fixed in 5b72c4d — `P3` — `partial`
- What WEB does: Tiptap with Underline, Link, TextAlign, TextStyle+Color, ResizableImage, Table (`components/email/rich-text-editor.tsx:191-225`), auto text direction (changelog 1.7.0), tables (#236).
- What RN does: bold/italic/underline/strike, H1/H2, lists, blockquote, align, link, image, clear, undo/redo (`ComposeScreen.tsx:1190-1240`; commands in `src/components/RichTextEditor.tsx:8-24`). No `dir="auto"` on the editor (`src/lib/editor-html.ts:244`).
- Fix hint: `execCommand('foreColor')` + a small palette, `insertHTML` for a basic table; set `dir="auto"` on `#editor`.
- [x] **Plain-text mode still renders the rich editor and derives text from HTML (links lose their href)** — fixed in c659b54 — `P3` — `partial` — note: the toggle now also lives under Composing; the duplicate in ReadingSettings (area 08 file, uncommitted edits there) was left in place
- What WEB does: plain-text mode swaps to a `<textarea>` (`email-composer.tsx:2875-2891`) and `htmlToPlainText` renders links as `text <href>` (`lib/html-to-text.ts:134-146`); the setting lives under Composing (#422).
- What RN does: `plainTextMode` only suppresses the HTML part at send (`ComposeScreen.tsx:951`); the user still sees formatting buttons; `htmlToPlainText` (`compose-html.ts:113-135`) strips `<a>` tags entirely so pasted links disappear from the text/plain alternative (which is the **only** part in plain-text mode). The toggle sits in `ReadingSettings.tsx:262` instead of Composing.
- Fix hint: hide the format bar and use a `TextInput multiline` when `plainTextMode`; in `htmlToPlainText` replace `<a href="X">T</a>` with `T <X>` before stripping tags; move the toggle to `ComposingSettings`.
- [x] **Inline image insert: no camera, no paste, no multi-select; not offered for file attachments either** — fixed in 5b72c4d — `P3` — `partial`
- What WEB does: drag/drop and paste of images embed as data URLs + cid (`email-composer.tsx:1519-1551`, changelog #163).
- What RN does: `insertInlineImage` (`ComposeScreen.tsx:693-732`) library only, single image; `pickFileAttachment` (`:646-673`) picks one file at a time; no `launchCameraAsync`.
- Fix hint: add a "Camera" option to the attach Alert (`ImagePicker.launchCameraAsync`) and allow multiple in `pickFileAsync` where supported.
- [x] **Send-time editor readback (RN #9/#11) — verified robust, one edge** — fixed in 5b72c4d — `P3` — `rn-only-bug`
- What RN does: `getHtml` rejects after 2 s when the page script is dead (`src/components/RichTextEditor.tsx:143-152`) and `performSend` aborts loudly (`ComposeScreen.tsx:890-902`); `editor-html.ts` is escape-free and unit-tested (`src/lib/__tests__/editor-html.test.ts`). The Android release fix (c5e5092) has since been superseded by the inline `source={{ html }}` + `originWhitelist={['about:blank']}` approach (`RichTextEditor.tsx:198-230`); `android/app/src/main/assets/editor.html` from that commit is no longer referenced (`grep editor.html` → only the TS module). Edge: `removeAttachment` (`ComposeScreen.tsx:553-571`) rewrites `bodyHtml` from the last `change` message rather than a live readback, so if the bridge lags it can `setHtml` a stale body and drop the last keystrokes.
- Fix hint: use `await editorRef.current.getHtml()` before stripping the `<img data-cid>`; delete the orphaned `editor.html` asset.
### Attachments / upload
- [x] **No byte progress, cancel does not abort the transfer, no size-limit check** — fixed in 5b72c4d — `P2` — `partial` (changelog 1.9.0 "Real byte progress…cancel aborts", 1652a0ec "respect server limits")
- What WEB does: XHR upload with `onProgress` + `AbortSignal` (`lib/jmap/client.ts:3954-3990`), chip progress bar (`email-composer.tsx:2951-2965`), `abortController.abort()` on remove (`:1605-1609`), `getMaxSizeUpload()` from the core capability (`lib/jmap/client.ts:4180-4181`; used for files at `components/files/files-app.tsx:311-336`).
- What RN does: `uploadBlob` (`src/api/blob.ts:5-62`) reads the whole file into memory (`new File(uri).bytes()`) and `fetch`es it with no progress or signal; `AttachmentChip` shows a spinner + "Uploading..." (`ComposeScreen.tsx:174-192`); `removeAttachment` (`:553-571`) removes the chip while the upload keeps running; no `maxSizeUpload`/`maxSizeAttachmentsPerEmail` check — a 60 MB video from the picker fails only after the full upload with a raw `Upload failed: 413`.
- Fix hint: check `session.capabilities['urn:ietf:params:jmap:core'].maxSizeUpload` (and the mail capability's `maxSizeAttachmentsPerEmail` for the running total) before starting; use `expo-file-system` `createUploadTask`/`uploadAsync` with progress callback and `cancelAsync()`, keep the handle on `AttachmentEntry`.
- [x] **Attachment chip has no preview / open** — fixed in 5b72c4d — `P3` — `missing`
- What WEB does: click a chip to preview via `FilePreviewModal` (`email-composer.tsx:1615-1646`).
- What RN does: chip is display-only (`ComposeScreen.tsx:164-200`).
- Fix hint: on press open the local `uri` with `expo-sharing`/`Linking`.
### Scheduled send
- [x] **Scheduled screen: cancel only — no reschedule, no "send now", no edit** — fixed in c659b54 — `P2` — `partial`
- What WEB does: scheduled view with send-now button (changelog 1.6.x), reschedule (`stores/email-store.ts:4353` rescheduleScheduledEmail, `lib/jmap/client.ts:7985`), cancel-for-edit that reopens the composer (`email-store.ts:4326`), listing routed to the owning account (#874).
- What RN does: `src/screens/ScheduledScreen.tsx:51-78` offers only "Cancel send"; `src/api/email.ts:902-988` has `listScheduledEmails`/`cancelScheduledSend` only.
- Fix hint: add `rescheduleScheduledSend(submissionId, emailId, identityId, holdFor)` = `EmailSubmission/set { update: {id: {undoStatus:'canceled'}}, create: {replacement: {emailId, identityId, envelope(HOLDFOR)}} }` and an edit path once drafts exist (cancel → open composer prefilled from the Email).
- [x] **Scheduled screen strings are hard-coded English** — fixed in c659b54 — `P3` — `rn-only-bug`
- What RN does: `ScheduledScreen.tsx:52-56`, `:69`, `:85` (`toLocaleString()` without locale/12h setting), `:118` "Scheduled", `:20`/`:88` "(no recipient)"/"(no subject)" — no `t()` calls at all, whereas `ComposeScreen` is localized.
- Fix hint: wrap in `useLocaleStore().t` and format with the shared date helper (`src/lib/date-format.ts`).
- [x] **Scheduled preset times ignore the user's 12/24h format** — fixed in c659b54 — `P3` — `rn-only-bug`
- What RN does: `ComposeScreen.tsx:966-970` and `:1321-1326` use `toLocaleString(undefined, …)` / `toLocaleString()` rather than the `timeFormat` setting the quote header already respects (`compose-html.ts:37-51`).
- Fix hint: reuse `formatQuoteDate`-style formatting with `timeFormat`.
### Templates
- [x] **No way to insert a template in the RN composer** — fixed in 5b72c4d — `P2` — `missing`
- What WEB does: template picker + "save as template" buttons in the composer (`email-composer.tsx:3030-3049`), `t` shortcut, insertion at the caret keeping typed text and the signature (#539/#540/#621, `:1278-1373`), placeholder auto-fill (`lib/template-utils.ts:46-62`, `components/templates/placeholder-fill-modal.tsx`), default recipients / identity per template (`lib/template-types.ts:8-13`).
- What RN does: templates can be created/edited/imported/exported in `src/components/settings/TemplateSettings.tsx` and stored in `src/stores/templates-store.ts`, but `ComposeScreen.tsx` never imports `useTemplatesStore` — the feature is unreachable from the composer.
- Fix hint: add a toolbar button that opens a bottom sheet listing `templates`; on pick set subject if empty and `editorRef.insertHtml(escaped body)` (the `insertHtml` bridge exists in `editor-html.ts:368-373`); substitute `{{date}}`, `{{day_of_week}}`, `{{sender_name}}` via a port of `getAutoFilledPlaceholders` and prompt for the rest.
- [x] **Template model lacks isHTML, category UI, favourites, default recipients, identity; export format differs** — fixed in c659b54 — `P3` — `partial`
- What WEB does: `lib/template-types.ts:1-17`, export envelope `{version:1,type:'webmail-templates',…}` (`lib/template-utils.ts:148-156`), import validation (`:286-351`), favourites/recent (`stores/template-store.ts`).
- What RN does: `templates-store.ts:307-316` has name/subject/body/category/isFavorite only; `exportAll` (`:395-397`) emits `{version:1,templates}` **without** `type:'webmail-templates'`, so the web `importTemplates` rejects an RN export with `invalid_type` (`lib/template-utils.ts:302-304`); RN import (`:399-434`) accepts web exports. Category/favourite are not editable in `TemplateSettings.tsx:68-81`.
- Fix hint: add `type: 'webmail-templates'` and `exportedAt` to `exportAll`; carry `isHTML`/`defaultRecipients`/`identityId` through import so they survive a round trip.
- [ ] **Templates not in cross-device settings sync (#825)** — `P3` — `missing` (blocked) — deferred: blocked on native settings sync (issue #1)
- What WEB does: templates ride in the per-account settings blob with tombstone merge (`stores/template-store.ts:208-218`, `lib/template-utils.ts:163-284`).
- What RN does: no settings sync exists at all (native issue #1 "Common settings / global settings" is open), so this is blocked on that feature.
- Fix hint: when settings sync lands, reuse `mergeSyncedTemplates` (pure TS).
### mailto / share intents
- [ ] **No `mailto:` handling / share-to-app intent** — `P2` — `missing` — composer side (prefill*, prefillAttachments upload) done in 5b72c4d; scheme/intent filters + Linking → Compose belong to area 08
- What WEB does: PWA `protocol_handlers` for mailto (`app/manifest.ts:118`), `parseMailtoUrl` (`lib/validation.ts:133-167`), in-app composer open through the unsaved-draft guard (`components/mail/mail-app.tsx:1110`, changelog 1.9.0).
- What RN does: `app.config.js:29` registers only the custom `bulwarkmobile` scheme; no `intentFilters` (Android `mailto`/`SEND`) or iOS `LSApplicationQueriesSchemes`/`CFBundleURLTypes` for `mailto`; no `Linking.getInitialURL`/`addEventListener('url')` handling that routes to `Compose` (grep over `App.tsx`/`src/navigation` shows none). Tapping a mailto link in another app cannot open Bulwark; sharing a file/text to the app is impossible.
- Fix hint: add `scheme: ['bulwarkmobile', 'mailto']` + Android `intentFilters` for `mailto` and `android.intent.action.SEND`, parse with a port of `parseMailtoUrl`, and navigate to `Compose` with `prefillTo`/subject/body (add those params); for SEND intents seed `attachments` from the shared `content://` URI (upload path already supports it, `src/api/blob.ts:17-22`).
### Security / crypto
- [ ] **S/MIME sign/encrypt on send is a stub** — `P3` — `missing` — deferred: no raw-MIME send path in RN; certificates are stored only
- What WEB does: crypto plugins hook `onComposeSend` (`email-composer.tsx:2180-2216`) and the store has `sendRawEmail` (`stores/email-store.ts:1724`).
- What RN does: `src/components/settings/SmimeSettings.tsx` lists imported certificates ("Your Certificates", "Recipient Certificates") but `ComposeScreen.tsx` has no sign/encrypt control (`grep -i smime|sign|encrypt` → none) and `sendEmail` has no raw-MIME path.
- Fix hint: out of scope for parity right now; document as a known gap in the settings pane ("certificates are stored but not yet used for sending").
### Misc
- [x] **Auto-add reply recipients to trusted senders** — fixed in 5b72c4d — `P3` — `missing` (changelog 1.5.x)
- What WEB does: after a reply, To/Cc are added to the trusted-senders list/address book (`email-composer.tsx:2283-2294`).
- What RN does: `settings-store.ts:105-106` has `trustedSenders`, but `ComposeScreen.tsx` never touches it (`grep -i trusted` → none).
- Fix hint: after a successful reply push `finalTo`/`finalCc` emails into `trustedSenders` (dedupe).
- [ ] **Offline send is not queued** — `P3` — `missing` (RN-only opportunity) — deferred: needs a new outbox op kind carrying the whole Email/set + submission payload; drafts now autosave to the server instead
- What RN does: `outbox-store.ts:40-43` only knows `keywords|mailboxes|destroy` ops; `performSend` fails with a network error when offline (`ComposeScreen.tsx:975-979`) and the composed text is lost unless the user keeps the screen open. WEB has no offline outbox either, so this is not a parity item, but once drafts exist the natural mobile behaviour is "save draft locally, submit when online".
- Fix hint: add a `send` op kind carrying the Email/set create + submission payload, flushed by `outbox-store.flush`.
- [x] **Permission / picker error strings are not localized** — fixed in 5b72c4d — `P3` — `rn-only-bug`
- What RN does: `ComposeScreen.tsx:620-623`, `:696-699`, `:719` hard-code "Photo library permission is required…", "Could not load image"; `identityError` fallback text at `:1052` ("identity unavailable", "Loading...").
- Fix hint: route through `t()` with keys in `locales/<lang>/common.json`.
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Scheduled send via `EmailSubmission` envelope `HOLDFOR` with explicit `rcptTo` (bare addresses, names stripped) and `maxDelayedSend`/FUTURERELEASE capability checks: `src/api/email.ts:828-844`, `src/api/jmap-client.ts:513-531`, `ComposeScreen.tsx:796-821` — matches `lib/jmap/client.ts:592-612`, `:3245-3258`.
- Undo-send delay setting (0/5/10/20/30 s) applied only when the server supports delayed send: `ComposeScreen.tsx:825-828`, `ComposingSettings.tsx:42-48`.
- Send-time readback of the live editor DOM with timeout + loud abort (RN #9/#11): `RichTextEditor.tsx:143-152`, `ComposeScreen.tsx:890-902`; escape-free page script with a parse test.
- `Name <addr>` recipients split into JMAP `name`/`email` (#672): `ComposeScreen.tsx:69-84`, `:944-947`.
- Duplicate recipients within one field rejected on blur/submit: `ComposeScreen.tsx:466-499`; suggestion list hides already-added addresses `:371-392`.
- Quote header values HTML-escaped (#482): `src/lib/compose-html.ts:88-100`.
- Inline images: data-URL placeholder in the editor, `cid:` rewrite + `disposition:'inline'` parts on send, orphaned cids not attached: `compose-html.ts:143-161`, `ComposeScreen.tsx:910-927`; removing the chip strips the `<img>`.
- Attachment reminder with word-boundary keyword match and configurable keyword list: `ComposeScreen.tsx:764-791`, `ComposingSettings.tsx:95-136`.
- Send blocked while uploads are in flight or errored (web waits instead; equivalent outcome): `ComposeScreen.tsx:502-516`.
- Body/attachment presence required to send; dirty-check close guard on the header X: `ComposeScreen.tsx:506-541`.
- Auto-select reply identity from original To/Cc + setting: `ComposeScreen.tsx:431-437`, `settings-store.ts:131`; preselect identity matching the active account (a971d5a1 / "pre-select primary sender identity"): `:426-429`.
- Identity picker bottom sheet (`src/components/IdentitySheet.tsx`), identity CRUD in settings with email immutable on edit (`IdentitySettings.tsx:263-277`).
- Guarded UUID generator (a92c852c equivalent): `src/lib/uuid.ts:6-20`.
- Pasted `<script>`/`<style>` stripped before send: `ComposeScreen.tsx:913` via `stripDangerousTags`; link insertion rejects non-http/mailto/tel schemes: `editor-html.ts:380-384`.
- Plain-text-only send (text/plain part only) when `plainTextMode`: `ComposeScreen.tsx:951`, `src/api/email.ts:798-806`.
- Blob upload tolerant of both Stalwart upload-response shapes: `src/api/blob.ts:38-61`.
- Scheduled list filters `undoStatus==='pending'` and future `sendAt`, cancel via `undoStatus:'canceled'` keeping the Sent copy: `src/api/email.ts:902-988`.
- Contact prefill from contact/group detail: `ContactDetailScreen.tsx:159`, `GroupDetailScreen.tsx:83`.
## N/A on mobile
- Ctrl/Cmd+Enter send, Ctrl+Shift+Enter schedule, `t` template shortcut, Escape close (`email-composer.tsx:2428-2468`).
- Drag-and-drop of files onto the composer and drag-to-reorder / drag-between-fields recipient chips (#593), chip drag preview.
- Pro multi-account From dropdown namespacing (44cdd083), per-pane composer tabs, tab-title logic (`lib/subject-prefix.ts:129-144` buildComposeTabTitle), "Keep the fresh-compose tab title clear".
- Composer sidebar / toolbar plugin slots and all `emailHooks.*` plugin transforms (no plugin runtime in RN).
- `beforeunload` server autosave (browser lifecycle) — replaced by the `beforeRemove` recommendation above.
- Browser `registerProtocolHandler`/PWA manifest mailto registration (replaced by OS intent filters above).
- Signature "double-click to unlock" atom (Tiptap-specific); the RN contenteditable is always editable.
- Quoted-HTML shadow-island typing detection (#654) — Tiptap/shadow-DOM specific.
- Send through another logged-in account's client for DKIM (#461) — RN is single-active-account; identities always belong to the submitting account.
- Route scheduled sends / identity on reschedule to a shared account (#874, a15af722) — RN does not compose on behalf of shared accounts.
+306
View File
@@ -0,0 +1,306 @@
# Calendar & tasks
## Summary
RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-viewer colours, client-side recurrence expansion that is a faithful port of WEB's, birthday calendar, tasks-only calendar hiding, push-driven refresh) but the write path is far behind WEB and has several data-corrupting bugs: the recurrence scope dialog is a stub (every choice edits/deletes the whole series, and editing an occurrence moves the whole series), all-day events grow by one day on every create/edit, events are created without a `timeZone`, and invitations are never actually sent because RN builds participants with the retired `sendTo` and never sets `organizerCalendarAddress`. There is no calendar management UI (create/rename/colour/delete/clear/share), no rights-based editability, no reminders/alerts firing, no trust assessment on invitations, no custom time zone, no Jalali, and the whole calendar UI is unlocalized even though RN has an i18n layer. Roughly half of the WEB changelog fixes since 1.4.5 apply to RN and are not there.
## Findings
### Recurrence
- [x] **Recurrence scope dialog is a stub: "This event" / "This and following" behave as "All events"** — `P1` — `missing` — fixed in 161baf1
- What WEB does: `handleScopeSelect` implements all three scopes: "this" writes a `recurrenceOverrides/<recurrenceId>` patch (or the synthetic id on Stalwart >= 0.16.20), "this_and_future" truncates the master with `until` and creates a new series, "all" patches the master; delete does the mirror (exclude / truncate / destroy) and refetches the range (ref `components/calendar/calendar-app.tsx:842-998`, `lib/recurrence-overrides.ts:21-32`, changelog 1.9.0 "Edit a single recurring occurrence via a one-shot override patch").
- What RN does: `handleScopeSelect` ignores the chosen scope with a TODO ("treat all scopes as 'all'") and either opens the edit modal on the occurrence or deletes `action.event.id`, which the store resolves to the master via `originalId` (ref `src/screens/CalendarScreen.tsx:347-363`, `src/stores/calendar-store.ts:306-329`). Choosing "This event" on delete destroys the entire series.
- Fix hint: port WEB's `handleScopeSelect`/`truncateRecurrenceAtEvent`/`findMasterEvent` into CalendarScreen; add `buildRecurrenceOverridePatch` (WEB `lib/recurrence-overrides.ts`) and `RECURRENCE_OVERRIDE_IMMUTABLE_KEYS`; for "this" write `{ [`recurrenceOverrides/${recurrenceId}`]: override }` on `originalId`; for delete-this write `{ excluded: true }`; refresh after.
- [x] **Editing an expanded occurrence rewrites the master's `start` to the occurrence date (series shifts / earlier occurrences vanish)** — `P1` — `bug` — fixed in 161baf1
- What WEB does: for scope "all" the updates go to the master but `start` from an occurrence is never applied to the master unless the user changed it; scope "this" goes into an override (ref `components/calendar/calendar-app.tsx:930-943`).
- What RN does: `EventModal` seeds `start` from the occurrence (`getEventStartDate(event)`, `src/components/calendar/EventModal.tsx:197`) and `handleSave` always sends `start` + `duration` (`EventModal.tsx:262-281`); `updateEvent` resolves the occurrence to `originalId` and PATCHes the master (`src/stores/calendar-store.ts:306-318`). Any occurrence edit moves the whole series to that occurrence's date.
- Fix hint: part of the scope work above; when scope is "all", drop `start` from the patch unless the user actually changed the date/time relative to the occurrence, or compute the delta and apply it to the master's `start`.
- [x] **Store's optimistic merge after an occurrence edit only updates one occurrence** — `P2` — `bug` — fixed in 161baf1
- What WEB does: after any occurrence or master mutation with expanded occurrences in view it refetches the visible range (`stores/calendar-store.ts:186-206, 749-751`).
- What RN does: `updateEvent` maps only `e.id === id` (`src/stores/calendar-store.ts:315-317`), so sibling occurrences keep the old title/time until a pull-to-refresh; `deleteEvent` removes only the tapped occurrence from state while the server destroyed the master (`:320-329`).
- Fix hint: after mutating a `recurrenceId`/`recurrenceRules` event call `get().refresh()` (RN already has it) like WEB's `refetchAfterOccurrenceMutation`.
- [x] **RSCALE=GREGORIAN;SKIP=OMIT still emitted by the custom recurrence editor (#805)** — `P2` — `bugfix-parity` — fixed in 58a6f45
- What WEB does: `createRecurrenceRule` deliberately omits `rscale`/`skip` because Stalwart serialises them into the RRULE and DAVx5 rejects it (ref `lib/recurrence-rule.ts:5-33`, changelog 1.9.2 #805).
- What RN does: `buildRuleFromEditorValue` sets `rscale: 'gregorian', skip: 'omit'` on every custom rule (ref `src/lib/recurrence.ts:220-221`). RN affected: any custom recurrence created on mobile breaks Android CalDAV sync.
- Fix hint: delete the two properties in `buildRuleFromEditorValue`; the simple presets in `EventModal.tsx:143-146` are already clean.
- [ ] **Synthetic occurrence ids / server-side expansion (Stalwart >= 0.16.20) not used** — `P3` — `missing` — deferred: optional; client-side expansion is at parity and the scope dialog now writes overrides through the master, so the synthetic-id path buys nothing functionally yet
- What WEB does: probes `CalendarEvent/set` with `h333333` once, then queries with `expandRecurrences: true`, hydrates occurrences from base events and writes occurrence patches through the synthetic id with a fallback to base-event overrides (ref `lib/jmap/client.ts:5648-5680, 5747-5751, 5828-5868`, `lib/recurrence-instances.ts:35, 73, 208-232`, `stores/calendar-store.ts:82-184`).
- What RN does: always fetches raw events and expands on the client (`src/stores/calendar-store.ts:228`, `src/lib/recurrence-expansion.ts`). Functionally fine on all server versions; RN's expansion file is a line-for-line port of WEB's (diffed: only comments, formatting and the `isServerRecurrenceInstance` pass-through differ).
- Fix hint: optional. If adopted, port `recurrence-instances.ts` wholesale and the `isServerRecurrenceInstance` guard in `expandRecurringEvents`.
- [x] **Removing recurrence / participants / reminders / location on edit does not persist** — `P2` — `bug` — fixed in 58a6f45
- What WEB does: sends explicit `null` for `recurrenceRules` (+ `recurrenceOverrides`, `excludedRecurrenceRules`), `alerts`, `participants` (+ `replyTo`, `organizerCalendarAddress`), `locations`, `virtualLocations` when the field was cleared (ref `components/calendar/event-modal.tsx:528-549, 560-563, 588-592`).
- What RN does: `handleSave` emits `undefined` for cleared fields (`recurrenceRules: undefined`, `participants: undefined`, `alerts: remindersToAlerts([]) === undefined`, `locations: undefined`) which JSON serialisation drops, so the server keeps the old value (ref `src/components/calendar/EventModal.tsx:262-281`, `src/lib/calendar-alerts.ts:72-73`). Setting "Does not repeat" on a recurring event, removing all attendees, deleting the last reminder or clearing the location silently does nothing.
- Fix hint: in edit mode compare against `event` and send `null` for fields that existed and are now empty; RN's `cleanRecurrenceRules` already turns `[]`/`null` into `recurrenceRule: null` (`src/api/calendar.ts:115-118`), so `recurrenceRules: []` works today for recurrence.
- [x] **`this_and_future` / huge-span RRULE (#735) — RN not affected** — `P3` — `bugfix-parity` — verified, nothing to fix (caps already in place; "this and following" truncates with `until` and drops `count`)
- What WEB does: memory note on #735 (Stalwart u32 overflow for spans > 136 years). WEB's editor caps `until` to a picked date and `count` to 999.
- What RN does: same caps (`src/lib/recurrence.ts:227-229`, `RecurrenceEditor.tsx:139-144, 244-249`), no alarms with absolute triggers far in the future. Not affected; noted so it isn't re-investigated.
### Events: create / edit
- [x] **All-day events are created and saved one day too long** — `P1` — `rn-only-bug` — fixed in 58a6f45
- What WEB does: seeds the end field from `getEventDisplayEndDate` (inclusive, end - 1 ms) for all-day events and builds `buildAllDayDuration(start, inclusiveEnd)` (ref `components/calendar/event-modal.tsx:274-280, 471-477`).
- What RN does: edit seeds `end` with `getEventEndDate(event)` which is the exclusive end (start + P1D = next day 00:00) (`src/components/calendar/EventModal.tsx:198`); the all-day toggle effect pushes `end` to the next day whenever `end <= start` (`:231-242`); `handleSave` then calls `buildAllDayDuration(start, end)` which is inclusive (`:269`, `src/lib/calendar-utils.ts:183-189`), yielding `P2D` for a one-day event. Every create-via-toggle and every re-save of an all-day event adds a day.
- Fix hint: seed/display the inclusive end (`getEventDisplayEndDate`) for all-day events, and in the all-day effect set `e = s` (same day) when `end <= start`; keep `buildAllDayDuration` inclusive.
- [x] **Events created without `timeZone` (floating time)** — `P1` — `bug` — fixed in 58a6f45
- What WEB does: every timed event carries `timeZone: getEffectiveTimeZone()` (all-day: `null`) so other clients and invitees resolve the wall-clock correctly (ref `components/calendar/event-modal.tsx:494-502`, `lib/timezone.ts:64-75`).
- What RN does: `handleSave` never sets `timeZone` (`src/components/calendar/EventModal.tsx:262-281`) and `createEvent` does not add one (`src/api/calendar.ts:248-275`). Stalwart stores a floating DTSTART; attendees in other zones, CalDAV clients, and the user after travelling see wrong times; iMIP invites sent for such events are ambiguous.
- Fix hint: set `timeZone: allDay ? null : Intl.DateTimeFormat().resolvedOptions().timeZone` (reuse `getUserTimeZone` from `src/api/calendar.ts:15`); once a custom time-zone setting exists (see below) use that.
- [x] **Changing the calendar in the edit modal is ignored (cannot move an event between calendars)** — `P2` — `rn-only-bug` — fixed in 161baf1 + 3fd5f10 (cross-account moves are not offered)
- What WEB does: `calendarIds: { [calendarId]: true }` is part of the saved patch and the store remaps store ids to server ids (ref `components/calendar/event-modal.tsx:503`, `stores/calendar-store.ts:706-715`).
- What RN does: `EventModal.handleSave` passes `calendarId` as the second argument, but `CalendarScreen.handleSave` only uses it on create (`src/screens/CalendarScreen.tsx:365-374`); the picker in edit mode is a no-op.
- Fix hint: on update include `calendarIds: { [cal.originalId || calendarId]: true }` in the patch; for shared calendars the owning account differs, so either block cross-account moves or delete + recreate.
- [x] **Edit-modal calendar picker offers the Birthdays calendar, iCal-subscription calendars and read-only shared calendars (#762)** — `P2` — `bugfix-parity` — fixed in 3fd5f10
- What WEB does: `canCreateEventsIn` excludes subscription calendars and calendars without `mayWriteAll`/`mayWriteOwn` (ref `lib/calendar-editability.ts:20-24`, changelog 1.9.0 #762).
- What RN does: `EventModal` receives `eventCalendars`, which still contains the virtual birthday calendar (`src/screens/CalendarScreen.tsx:213-216, 227-231, 544-552`) and all shared calendars regardless of rights; picking Birthdays yields a server error, picking a subscription calendar lets the next feed sync delete the event (`src/stores/calendar-subscriptions-store.ts:58-74`).
- Fix hint: filter with a RN `canCreateEventsIn(cal, isSubscriptionCalendar)` (subscriptions from `useCalendarSubscriptionsStore`), exclude `BIRTHDAY_CALENDAR_ID`.
- [x] **No rights-first editability (edit/delete offered on read-only and RSVP-only events)** — `P2` — `missing` — fixed in 3fd5f10
- What WEB does: `getEventEditability` gates on calendar `myRights` first (`mayWriteAll`, `mayWriteOwn` + owner, `mayRSVP` + participant), subscription calendars are read-only, and the popover shows edit/delete vs. RSVP-only accordingly (ref `lib/calendar-editability.ts:47-76`, `components/calendar/event-detail-popover.tsx:161-171, 540-575`, changelog 1.9.0 "Rights-first event editability, including alias organizers").
- What RN does: `EventDetailSheet` always renders Edit/Delete (`src/components/calendar/EventDetailSheet.tsx:328-351`); the only read-only guard is the birthday calendar (`src/screens/CalendarScreen.tsx:313-316`). The server rejects the write, but the user sees a generic error.
- Fix hint: port `calendar-editability.ts`; pass `calendarsById`, the user's addresses (identities + aliases) and `isSubscriptionCalendar` into the sheet; hide Edit/Delete for `read-only`, show only RSVP for `rsvp-only`.
- [x] **`myRights.mayWrite` (non-existent JMAP property) used for writability everywhere** — `P2` — `rn-only-bug` — fixed in 3fd5f10
- What WEB does: uses `mayWriteAll`/`mayWriteOwn`/`mayDelete` from RFC-style `CalendarRights` (ref `lib/calendar-editability.ts:20-24`, `components/calendar/calendar-sidebar-panel.tsx:328-334`).
- What RN does: `CalendarRights.mayWrite` is documented as a "legacy short flag" (`src/api/types.ts:~507`) and is what `CalendarSidebarDrawer` (`:92-98`), `TasksSheet` (`:84-87`), `ICalImportSheet` (`:51-54`) and `CalendarInvitationBanner` (`:96`) test. Stalwart never sends it, so every calendar counts as writable and the "Subscribed" section can never populate.
- Fix hint: replace with `!r || r.mayWriteAll || r.mayWriteOwn` and drop `mayWrite` from the type.
- [x] **No "send invitations" toggle in the editor** — `P3` — `missing` — fixed in 2abea97
- What WEB does: `sendInvitations` checkbox (default on) decides `sendSchedulingMessages` (ref `components/calendar/event-modal.tsx:409, 594, 1333-1338`).
- What RN does: always sends when participants exist (`src/stores/calendar-store.ts:22-24, 293-297`).
- Fix hint: add a switch in the Participants section and thread it through `onSave`.
- [x] **Reminder picker is presets-only** — `P3` — `partial` — fixed in 2abea97 (custom amount + unit row; absolute / end-relative / non-display alerts survive a save)
- What WEB does: alert rows with a number + unit (minutes/hours/days/weeks/at time), multiple rows, preserving exotic alerts (ref `components/calendar/event-modal.tsx:101-135, 358-386`, changelog 1.6.x #170).
- What RN does: fixed preset list, multiple allowed, non-offset alerts are dropped from the UI but kept until save (`src/components/calendar/EventModal.tsx:299-312`, `src/lib/calendar-alerts.ts:51-70`). Note the "kept until save" comment is wrong: `remindersToAlerts` rebuilds the map from presets only, so an absolute-trigger alert is lost on the next save.
- Fix hint: add a custom value+unit row; when rebuilding alerts, carry over alerts whose trigger has no `offset`.
- [ ] **Duplicate event, export .ics, copy meeting link / title, add note** — `P3` — `missing` — duplicate (+1 day, opens editor), share as .ics (`eventToICS` port + expo-sharing) and copy meeting link done in 5fe8e40; deferred: copy title, append-a-note
- What WEB does: popover/context-menu actions duplicate (+1 day, opens editor), export via `downloadEventICS`, copy meeting link, copy title, append a timestamped note to the description (ref `components/calendar/calendar-app.tsx:1017-1125`, `lib/calendar-ics-export.ts:96, 176`).
- What RN does: `EventDetailSheet` has an `onDuplicate` prop that `CalendarScreen` never passes (`src/components/calendar/EventDetailSheet.tsx:59, 336-342`); no export/copy/note.
- Fix hint: wire `onDuplicate` (clone like WEB `handleDuplicateFromDetail`), add "Share .ics" via `expo-sharing` + a port of `eventToICS`, add "Copy link" with `Clipboard`.
- [ ] **Quick natural-language event input** — `P3` — `missing` — deferred: low priority, needs a port of the webmail's quick-event parser and a text field above the month grid
- What WEB does: `QuickEventInput` in the toolbar (ref `components/calendar/quick-event-input.tsx`, `calendar-toolbar.tsx`).
- What RN does: none.
- Fix hint: low priority; could reuse WEB's parser in a text field above the month grid.
### Invitations / scheduling
- [x] **Invitations are never sent: participants built with retired `sendTo`, no organizer, no `organizerCalendarAddress`** — `P1` — `bugfix-parity` — fixed in 5dc4070
- What WEB does: `buildParticipantMap` emits an owner-only organizer participant with `calendarAddress`, attendees with `calendarAddress` + `scheduleAgent: 'server'`, and the modal sets `organizerCalendarAddress: mailto:<user>` — without it Stalwart emits no ORGANIZER and silently skips iTIP (ref `lib/calendar-participants.ts:164-214`, `components/calendar/event-modal.tsx:573-586`, changelog 1.7.0 "Send calendar invites by setting organizerCalendarAddress", 1.7.2 #500 "Use calendarAddress, drop retired sendTo", 1.8.1 #731).
- What RN does: `ParticipantInput.addParticipant` writes `{ email, sendTo: { imip }, roles: { attendee } }` (`src/components/calendar/ParticipantInput.tsx:71-92`), no organizer participant and no `organizerCalendarAddress` anywhere in `EventModal.handleSave` (`:262-281`). `sendSchedulingMessages: true` is passed (`src/stores/calendar-store.ts:293-297`) but Stalwart has nothing to schedule with. On Stalwart the attendee's `sendTo` is stored as an inert JSPROP.
- Fix hint: port `buildParticipantMap` + `collectUserCalendarAddresses`; on save with attendees set `participants = buildParticipantMap({name, email: activeAccount.email}, attendees)` and `organizerCalendarAddress = mailto:<email>`; on edit keep existing participant ids and never re-add the organizer (#731).
- [x] **RSVP matches only the active account e-mail (no identities / aliases)** — `P2` — `missing` — fixed in 5dc4070
- What WEB does: `currentUserEmails` = identities + account aliases via `collectUserCalendarAddresses`, used for `getUserParticipantId`, organizer detection and editability (ref `lib/calendar-participants.ts:71-112`, changelog 1.9.0 "including alias organizers").
- What RN does: `findParticipantByEmail(event, [activeEmail])` with the single login address (`src/components/calendar/EventDetailSheet.tsx:115, 173`, `src/components/email/CalendarInvitationBanner.tsx:98`). Invitations addressed to an alias show no RSVP buttons.
- Fix hint: RN already loads identities for the composer; collect their emails (+ aliases from `x:Account/get` if available) and pass the array.
- [x] **RSVP writes `replyTo` instead of repairing `organizerCalendarAddress`** — `P3` — `partial` — fixed in 5dc4070
- What WEB does: only when the event lacks `organizerCalendarAddress` it sets it from `replyTo.imip`; never sends `replyTo` (retired) (ref `stores/calendar-store.ts:782-789`).
- What RN does: `rsvpEvent` sets `patch.replyTo = replyTo` whenever `buildReplyTo` returns something (`src/api/calendar.ts:344`, `src/lib/calendar-invitation.ts:74-83`). Harmless on Stalwart (ignored) but the repair path for imported invites lacking an organizer is missing, so those REPLYs are not routed.
- Fix hint: mirror WEB: `if (replyTo?.imip && !storeEvent.organizerCalendarAddress) patch.organizerCalendarAddress = replyTo.imip`.
- [x] **Store rejects participant ids containing `..`** — `P3` — `rn-only-bug` — fixed in 5dc4070
- What WEB does: participant ids are opaque and RFC 6901-escaped; only empty ids are rejected (ref `stores/calendar-store.ts:763-770`).
- What RN does: `if (!participantId || participantId.includes('..')) throw` (`src/stores/calendar-store.ts:332-334`); a server-generated id like `a..b` cannot RSVP.
- Fix hint: drop the `..` check.
- [x] **Invitation banner: no trust assessment, no METHOD from Content-Type / raw ICS, no inline body-part detection** — `P2` — `partial` — fixed in 8206893 (auth results parsed from `email.headers`)
- What WEB does: `getInvitationTrustAssessment` (DMARC/DKIM/SPF + sender/organizer mismatch -> trusted/caution/warning banner), `getInvitationMethod` reads `method=` from the attachment/body Content-Type, then `extractMethodFromRawIcs`, then infers; `findCalendarAttachment` also walks `textBody`/`htmlBody` sub-parts (ref `lib/calendar-invitation.ts:341-446`, changelog 1.6.x "RSVP with trust assessment").
- What RN does: `inferInvitationMethod(parsed)` only (`src/components/email/CalendarInvitationBanner.tsx:62`; `extractMethodFromRawIcs` exists at `src/lib/calendar-invitation.ts:103-106` but is unused); `findCalendarAttachment` checks `email.attachments` only (`:139-148`); no trust UI. A spoofed invitation from an unauthenticated sender looks identical to a real one, and a REPLY/CANCEL whose ICS carries no participants is treated as `unknown` and offered "Add to calendar".
- Fix hint: port `getInvitationTrustAssessment` (RN `Email` has `authenticationResults`?; if not, add to the fetched properties) and show a coloured banner + reason; read `attachment.type` params and fall back to downloading the blob text for `METHOD:`.
- [x] **Invitation banner imports into the first writable calendar, not the default calendar, and parses against the primary account** — `P3` — `partial` — fixed in 3fd5f10 + 8206893
- What WEB does: imports into the default calendar of the account that owns the email; shared-folder emails are parsed against the folder owner (#867) and multi-account lookups are routed by source account (#847) (changelog 1.9.0).
- What RN does: `calendars.find(cal => !cal.myRights || cal.myRights.mayWrite !== false)` (`src/components/email/CalendarInvitationBanner.tsx:96`) and `parseCalendarBlob(attachment.blobId)` on `jmapClient.accountId` (`:59`, `src/api/calendar.ts:355-372`). RN shows only the active account's mailboxes, so #847/#867 are largely N/A; the "first writable" pick is wrong when a subscription calendar sorts first.
- Fix hint: prefer `cal.isDefault && !cal.isShared`, exclude subscription/birthday calendars; pass `email.accountId` (if RN exposes shared mailboxes later) to `parseCalendarBlob`.
- [x] **Organizer not identified in the participant list; no "invited by", status counts or cancelled/tentative rendering** — `P3` — `partial` — fixed in 5dc4070 + 0f80e98 (organizer label, cancelled strike-through in sheet/card/month chips, tentative badge on cards; week-view blocks are untouched)
- What WEB does: `getParticipantList` marks the organizer (also via `organizerCalendarAddress`, #731), the popover shows "(organizer)", strikes through `status === 'cancelled'` and badges `tentative` (ref `lib/calendar-participants.ts:126-157`, `components/calendar/event-detail-popover.tsx:299-312, 445-449`, changelog 1.7.2 #572).
- What RN does: participants listed with status dots only (`src/components/calendar/EventDetailSheet.tsx:298-325`); no cancelled/tentative styling in sheet, `EventCard` or views.
- Fix hint: port `getParticipantList`/`getStatusCounts`; add `line-through` when `event.status === 'cancelled'` in `EventCard`, `WeekView`, `EventDetailSheet`.
### Calendars
- [x] **No calendar management UI: create, rename, colour, description, delete, clear events, copy CalDAV URL** — `P2` — `missing` — fixed in dae5700 (no CalDAV URL copy: RN has no DAV base URL)
- What WEB does: sidebar context menu + settings section with create (with kind picker), edit name/colour/description, clear all events (unlink-aware), delete, share, copy URL (ref `components/calendar/calendar-sidebar-panel.tsx:328-395`, `components/settings/calendar-management-settings.tsx`, `stores/calendar-store.ts:1028-1206`).
- What RN does: `createCalendar` exists in the store but is only reachable through the subscription sheet (`src/stores/calendar-store.ts:378-382`, `src/stores/calendar-subscriptions-store.ts:82-84`); there is no `updateCalendar`/`removeCalendar`/`clearCalendarEvents` API or UI (`src/api/calendar.ts` has `deleteCalendar` at 417 but nothing calls it for own calendars). Long-press only offers set-default and shared-calendar recolour (`src/components/calendar/CalendarSidebarDrawer.tsx:208-211`).
- Fix hint: add `Calendar/set` update + destroy (`onDestroyRemoveEvents`) to `src/api/calendar.ts`, expose in the store, and add "New calendar" + long-press "Rename / Colour / Delete / Clear" in the drawer. Deleting must be blocked for `isDefault` and shared calendars like WEB.
- [x] **Own-calendar colour cannot be changed (only shared calendars are recolourable)** — `P3` — `partial` — fixed in dae5700
- What WEB does: colour picker writes `Calendar/set { color }` for own calendars and a local override for shared ones (ref `calendar-sidebar-panel.tsx:330, 366`, `lib/shared-calendar-colors.ts:33-40`).
- What RN does: `canRecolor = !!onSetColor && !!cal.isShared` (`CalendarSidebarDrawer.tsx:209`).
- Fix hint: for `!cal.isShared` call the new `updateCalendar(id, { color })`.
- [x] **Shared-calendar colour key differs from WEB (not portable, but internally consistent)** — `P3` — `partial` — fixed in 0f80e98
- What WEB does: key = `${localAccountId}|${accountId}|${originalId}` (ref `lib/shared-calendar-colors.ts:33-40`).
- What RN does: key = `${accountId}|${id}` where `id` is already the namespaced `${accountId}:${raw}` (`src/lib/calendar-utils.ts:474-478`). Works, but the account id is duplicated in the key and would break if the namespacing format changes.
- Fix hint: use `cal.originalId ?? cal.id` like WEB.
- [ ] **Calendar creation does not pin `supported-calendar-component-set` (#760)** — `P3` — `bugfix-parity` — deferred: the drawer now creates calendars over `Calendar/set` (dae5700); MKCALENDAR against Stalwart's `/dav/` endpoint needs a CalDAV client + calendar-home discovery that RN does not have
- What WEB does: MKCALENDAR through the WebDAV proxy with `VEVENT`/`VTODO`/both, then finishes over JMAP; falls back to `Calendar/set` (ref `lib/jmap/client.ts:5366-5400, 5436-5485`, `components/calendar/calendar-kind-picker.tsx:16-18`, changelog 1.9.0 #760).
- What RN does: plain `Calendar/set` (`src/api/calendar.ts:374-396`). Only matters once RN gains a create-calendar UI; RN has no WebDAV proxy, but it can talk to Stalwart's CalDAV endpoint directly (the same host serves `/dav/`).
- Fix hint: when adding calendar creation, issue `MKCALENDAR` against the account's calendar-home with `supported-calendar-component-set`, then `Calendar/get` to find the new id.
- [x] **No first-touch gate: duplicate default calendars on clustered Stalwart (#907)** — `P2` — `bugfix-parity` — fixed in 58e3ef6 (in-flight dedupe of fetchCalendars/fetchTasks; ensureRange awaits it)
- What WEB does: `FirstTouchGate` serialises the first `Calendar/*`/`CalendarEvent/*` request per account so concurrent first-touch requests cannot each create a default calendar (ref `lib/jmap/first-touch-gate.ts:56-101`, `lib/jmap/client.ts:678, 1129`, changelog 1.9.0 #907).
- What RN does: `CalendarScreen` fires `fetchCalendars()` on mount and, in a second effect, `ensureRange()` which sees `calendars.length === 0` and calls `fetchCalendars()` again then `CalendarEvent/query` (`src/screens/CalendarScreen.tsx:261-269`, `src/stores/calendar-store.ts:235-251`); `auth-store` also kicks `fetchCalendars`/`refresh` after login (`src/stores/auth-store.ts:92-98`). Two to three concurrent first-touch calls — exactly the #907 race. RN affected.
- Fix hint: port `FirstTouchGate` into `src/api/jmap-client.ts` `request()`, or at minimum dedupe in-flight `fetchCalendars` and make `ensureRange` await the mount fetch.
- [x] **Tasks-only calendar detection misses CalDAV tasks without `@type`** — `P2` — `bugfix-parity` — fixed in 58e3ef6
- What WEB does: `isTaskLikeObject` treats `@type: Task` OR presence of `due`/`progress`/`percentComplete` (and `@type !== 'Event'`) as a task, both for the tasks list and for hiding tasks-only calendars (ref `lib/calendar-component-detection.ts:30-58`, `lib/jmap/client.ts:6300-6316`, changelog 1.5.x #84 "Detect tasks created by external CalDAV clients such as Thunderbird").
- What RN does: `fetchEvents` classifies solely on `'@type' === 'Task'` (`src/stores/calendar-store.ts:208-227`); Thunderbird/Todoist VTODOs that Stalwart returns without `@type` are neither shown as tasks nor counted for `taskOnlyCalendarIds` (they fall through as start-less "events" and are dropped by `!!e.start`).
- Fix hint: port `isTaskLikeObject` and use it in both filters.
- [x] **Shared accounts without calendar access are re-probed on every fetch** — `P3` — `bugfix-parity` — fixed in 58e3ef6
- What WEB does: remembers `forbidden`/`accountNotFound` per account in `calendarAccessDenied` and skips it (ref `lib/jmap/client.ts:5806-5813`, changelog 1.8.1 "Stop re-probing shared accounts that have no calendar access").
- What RN does: `getCalendars` and `fetchEvents` try every calendar-capable session account each time and swallow errors (`src/api/calendar.ts:140-186`, `src/stores/calendar-store.ts:194-204`).
- Fix hint: keep a module-level `Set` of denied account ids, reset on reconnect.
- [x] **Calendar sharing (JMAP `shareWith`) not available for calendars** — `P2` — `missing` — fixed in dae5700
- What WEB does: `shareCalendar` via `Calendar/set shareWith/<principal>` with a principal picker and share indicators (ref `stores/calendar-store.ts:1078-1100`, `lib/jmap/client.ts:4921`, changelog 1.6.x "JMAP sharing for calendars and address books", #244, #257).
- What RN does: sharing exists only for Files (`src/api/files.ts:229-241`, `src/components/files/ShareSheet.tsx`); nothing for calendars (also likely nothing for address books — that belongs to the contacts audit).
- Fix hint: reuse `ShareSheet` + the principal picker with a `Calendar/set` `shareWith/{principalId}` patch; request `shareWith`/`myRights` in `Calendar/get` properties.
### Views / navigation / locale
- [ ] **Entire calendar UI is hard-coded English although RN has i18n** — `P2` — `missing` — partial in 0f80e98: screen, month/week/agenda views, event card/sheet/modal, drawer, scope dialog, settings, banner and calendar/share sheets use `t()` + date-fns locales; TasksSheet (09c24fa) and `formatReminder` (2abea97) localized too; deferred: ICalImportSheet, ICalSubscriptionSheet and RecurrenceEditor still hard-code English
- What WEB does: everything through `next-intl` (`t('calendar.*')`), month/day names and popover dates localized (changelog 1.6.x "Localize event start date in detail popover and event modal").
- What RN does: `src/i18n/index.ts` + `useLocaleStore` are used by 18 other files, but none of `src/components/calendar/*`, `src/screens/CalendarScreen.tsx` or `CalendarSettings.tsx` import it; all labels ("Today", "No events", "Does not repeat", "Going?", RSVP labels, drawer titles, etc.) and `date-fns` `format()` calls without a `locale` are English (`CalendarScreen.tsx:408, 448, 504, 640-641`, `EventModal.tsx:60-67, 326, ...`, `MonthView.tsx:25-26`, `AgendaView.tsx:33-37`).
- Fix hint: add a `calendar.*` namespace to `locales/*.json` (WEB's keys can be copied) and pass a date-fns locale from the locale store into `format()`.
- [ ] **First day of week limited to Monday/Sunday; no Saturday, no Jalali (#490)** — `P3` — `missing` — Saturday (0|1|6) done in 0f80e98; deferred: Jalali month grid needs a port of `lib/jalali-utils.ts` + `useCalendarLocale`
- What WEB does: `firstDayOfWeek` 0/1/6 with Saturday default for Persian and a full Jalali month grid / headers (ref `hooks/use-calendar-locale.ts:35-120`, `lib/jalali-utils.ts:161-169`, changelog 1.8.0 #490).
- What RN does: `FirstDayOfWeek = 0 | 1` (`src/stores/settings-store.ts:76`), `MonthView`/`WeekView` typed `0 | 1` (`MonthView.tsx:34`, `WeekView.tsx:40`), no Jalali.
- Fix hint: widen the type to `0|1|6`, add Saturday to `CalendarSettings`; Jalali is a larger port of `jalali-utils.ts` + a `useCalendarLocale` equivalent (only if the fa locale is added to RN).
- [x] **Week numbers are not ISO when the week starts on Monday; no week numbers in week view** — `P3` — `rn-only-bug` — fixed in 0f80e98 (month view; the week view header has no room for it)
- What WEB does: `getISOWeek` when `weekStartsOn === 1`, else `getWeek(..., { weekStartsOn: 0 })` (ref `components/calendar/mini-calendar.tsx:74-79`).
- What RN does: `getWeek(row[0], { weekStartsOn })` (`src/components/calendar/MonthView.tsx:137`) — without `firstWeekContainsDate: 4` this is not ISO numbering and can be off by one around New Year. WeekView has no week number.
- Fix hint: use `getISOWeek` for Monday start; optionally show the number in the WeekView header row.
- [x] **"Show time in month view" setting is ignored (#666)** — `P3` — `bugfix-parity` — fixed in 0f80e98
- What WEB does: on mobile, `showChips = !isMobile || showTimeInMonthView` renders event chips with times instead of dots (ref `components/calendar/calendar-month-view.tsx:49-52`, `event-card.tsx:80, 178`, changelog 1.8.0 #666).
- What RN does: setting exists (`src/stores/settings-store.ts:169`, `CalendarSettings.tsx:435-443`) but `MonthView` always draws up to 3 dots (`src/components/calendar/MonthView.tsx:83-86`). RN affected.
- Fix hint: pass the setting into `MonthView` and render a compact chip (title + start time) per event when on.
- [x] **Hover-preview setting exposed on mobile** — `P3` — `rn-only-bug` — fixed in 0f80e98 (store key kept for settings-sync compatibility)
- What WEB does: hover preview is a desktop pointer feature.
- What RN does: `CalendarSettings` shows "Hover preview" with delay options and nothing consumes `calendarHoverPreview` (`src/components/settings/CalendarSettings.tsx:87-102`; no other reference in `src/`).
- Fix hint: remove the setting row (keep the store key for settings-sync compatibility or drop it).
- [x] **No day view (falls back to agenda); tapping a month day does not open the day** — `P3` — `partial` — fixed in 0f80e98 ("Day" hidden from the mobile default-view options; a synced "day" shows as Agenda)
- What WEB does: day view with time grid; on mobile, tapping a date in month view switches to day view and offers "back to month" (ref `components/calendar/calendar-app.tsx:461-470`, `calendar-day-view.tsx`).
- What RN does: `calendarDefaultView === 'day'` maps to agenda (`src/screens/CalendarScreen.tsx:131-137`); month view shows a list below the grid instead. Acceptable UX, but the settings option "Day" is misleading.
- Fix hint: either reuse `WeekView` with a single-day `weekDays` array as a day grid, or hide "Day" from the RN default-view options.
- [x] **Week view: event block time label ignores 12h setting; timed grid has no long-press-at-minute precision** — `P3` — `partial` — fixed in 0f80e98
- What WEB does: `formatSnapTime(minutes, timeFormat)` (ref `lib/calendar-utils.ts:278`).
- What RN does: `minutesToTimeLabel` always `HH:mm` (`src/components/calendar/WeekView.tsx:315-319`) while the gutter honours 12h (`:229-231`). Long-press creates at the whole hour (`:123-128`), fine for mobile.
- Fix hint: use `timePattern(timeFormat)` with `format()`.
- [x] **EventCard shows the description under a location (MapPin) icon and never shows the location** — `P3` — `rn-only-bug` — fixed in 0f80e98
- What WEB does: card shows time, location and calendar (ref `components/calendar/event-card.tsx`).
- What RN does: `event.description` rendered with `<MapPin/>` (`src/components/calendar/EventCard.tsx:65-72`); `locations` is not read.
- Fix hint: show `Object.values(event.locations)[0]?.name` with MapPin; drop or move the description.
- [x] **Detail sheet shows exclusive end date for all-day events (#318 off-by-one)** — `P2` — `rn-only-bug` — fixed in 5dc4070
- What WEB does: multi-day all-day events show the inclusive end date (changelog 1.6.x #318, `getEventDisplayEndDate`).
- What RN does: `formatRange` uses `eventTimeRange` -> `getEventEndDate` (exclusive), so a single-day all-day event on Mar 1 prints "Mar 1 – Mar 2" and a two-day one "Mar 1 – Mar 3" (`src/components/calendar/EventDetailSheet.tsx:63-74`, `src/lib/calendar-utils.ts:84-90`).
- Fix hint: for `allDay` use `getEventDisplayEndDate(event)` before comparing/formatting.
- [ ] **Deep links to calendar/event** — `P3` — `missing` — deferred: no `linking` config exists yet (mail area plumbing); `Calendar` route still takes no params
- What WEB does: `parseCalendarPath`/`buildCalendarPath` handle `/calendar/<view>/<date>?event=` (ref `lib/deep-links.ts:272-300`, changelog 1.8.3 "Deep links for mail, calendar, contacts, files").
- What RN does: `Calendar: undefined` route params (`src/navigation/types.ts:33`); no linking config for calendar. Only matters once RN adds a `linking` config; the mail area may already cover the plumbing.
- Fix hint: accept `{ date?, eventId? }` params on the Calendar route and open the detail sheet.
### Data loading / sync
- [x] **Range queries fetch every event in the account (no `after`/`before`), capped at 1000** — `P2` — `bug` — fixed in 58e3ef6
- What WEB does: `CalendarEvent/query` with `{ after, before }` + `timeZone` (changelog 1.7.0 "timezone-aware calendar queries", 1.7.1 "Stalwart-compatible calendar filters") (ref `lib/jmap/client.ts:5722-5745`).
- What RN does: `queryEvents` ignores its `_after`/`_before` arguments citing "Stalwart rejects after/before filters" (outdated) and uses `limit: 1000` (`src/api/calendar.ts:188-208`); `fetchEvents` then re-fetches everything whenever `ensureRange` widens the union (`src/stores/calendar-store.ts:235-251`). Accounts with more than 1000 objects silently lose events; every navigation past the loaded range re-downloads all events.
- Fix hint: send `filter: { after, before, inCalendar... }` as WEB does (`{ operator: 'AND', conditions: [...] }` when combined), keep `timeZone`; then `loadedRange` can be the requested window rather than a growing union.
- [x] **iCal import passes raw parsed objects to `CalendarEvent/set`; existing UIDs are skipped instead of linked (#113)** — `P2` — `partial` — fixed in 5fe8e40
- What WEB does: whitelists the JSCalendar properties it sends (drops server-computed `utcStart`/`utcEnd`/`isOrigin`/`created`/`updated`, rewrites participants to `calendarAddress`, normalises all-day duration/timeZone) and links UIDs that exist in another calendar by adding the target calendar to `calendarIds` (ref `stores/calendar-store.ts:822-958`, changelog 1.5.x #113).
- What RN does: `importEvents` skips existing UIDs and batch-creates `{ ...parsed, calendarIds }` untouched (`src/stores/calendar-store.ts:352-376`, `src/api/calendar.ts:278-297`). Whether Stalwart rejects computed properties on create depends on the parse output; WEB chose to whitelist after hitting failures ("Deduplicate UIDs during iCal import to prevent mass failures").
- Fix hint: port WEB's `prepared` mapping (`stores/calendar-store.ts:885-957`) and the link-by-`calendarIds` branch.
- [x] **iCal subscriptions: no per-account scoping, no edit, no auto-refresh/interval, no rollback, `webcals://`, basic-auth URLs** — `P2` — `partial` — fixed in dae5700
- What WEB does: subscriptions carry `accountId` and are skipped for other accounts (`stores/calendar-store.ts:1360-1363, 1468-1472`), editable name/colour/url/interval (`:1294-1329`), refreshed every 5 min per interval (`components/calendar/calendar-app.tsx:320-324`), rolled back when the first fetch fails (`:1274-1289`), `webcals?://` normalised (`:1240`), basic auth in the URL supported server-side (#275), stale events unlinked rather than deleted when also in another calendar (`:1401-1427`).
- What RN does: `calendar-subscriptions-store` persists `{ id, name, url, color, calendarId }` globally (`src/stores/calendar-subscriptions-store.ts:16-24, 162-166`) — after switching accounts the list still shows and `syncSubscription` imports into a calendar id of the other account (or a colliding raw id); `syncAll` is never called (`:155-160`); no edit UI (`ICalSubscriptionSheet.tsx`); a failed first sync leaves the empty calendar + sub with `lastError` (`:82-116`); only `webcal://` is rewritten (`:38-40`); `fetch('https://user:pass@host')` throws in RN's WHATWG fetch so #275 URLs fail; stale events are deleted outright (`:58-74`).
- Fix hint: add `accountId: jmapClient.accountId` to each sub and filter by it; call `syncAll` on calendar mount + `AppState` foreground with a per-sub interval; add an edit sheet; on first-sync failure delete the calendar; regex `/^webcals?:\/\//i`; parse credentials out of the URL into an `Authorization` header.
- [x] **Subscription feeds are fetched on-device (security note, not a gap)** — `P3` — `partial` — fixed in dae5700 (10 MB cap)
- What WEB does: `/api/fetch-ical` server-side with an SSRF guard, DNS pinning and redirect validation (GHSA-24w9, changelog 1.9.2).
- What RN does: `fetch(url)` from the phone (`src/stores/calendar-subscriptions-store.ts:42-53`). No server is exposed, so the SSRF class does not apply; the device can be pointed at LAN hosts, which is the user's own network. Redirects are followed by `fetch` without validation (fine). N/A for the advisory; worth a 10 MB size cap and a `text/calendar`-ish content check (already checks `BEGIN:VCALENDAR`).
- Fix hint: optional size cap.
- [x] **Created recurring event appears as a single instance until refresh; created event lacks `utcStart`/`utcEnd`** — `P3` — `partial` — fixed in 58e3ef6
- What WEB does: `createCalendarEvent` refetches the created event with full properties (`lib/jmap/client.ts:5942-5960`) and the store refetches when occurrences are in view.
- What RN does: `createEvent` merges the `/set` echo over the payload (`src/api/calendar.ts:272-274`) and appends without expansion (`src/stores/calendar-store.ts:286-304`).
- Fix hint: after create, `CalendarEvent/get` the id (RN `getEvents([id])`) and, if `recurrenceRules` is set, run `expandRecurringEvents` for `loadedRange` or call `refresh()`.
- [ ] **Push: FCM subscription excludes `CalendarEvent`/`Calendar` types** — `P3` — `partial` — deferred: local reminders (8e1d03c) are scheduled from foreground fetches only; adding the calendar types to the FCM subscription lives in `src/lib/push-notifications.ts` (push area) and would need a background refresh + reschedule hook
- What WEB does: push subscription covers calendar types so the sidebar refreshes on external changes (FEATURES.md "JMAP push keeps everything in sync").
- What RN does: foreground EventSource/polling dispatches to `useCalendarStore.handleStateChange` (`App.tsx:390-397`, `src/stores/calendar-store.ts:261-284`) — at parity while the app is open; the background FCM subscription lists only `['Email','EmailDelivery','Mailbox']` (`src/lib/push-notifications.ts:104`), so calendar changes never wake the app. Acceptable unless local reminders are added.
- Fix hint: leave as is unless background calendar refresh becomes needed.
### Alerts / notifications
- [x] **Reminders never fire on mobile (no alert scheduler, no local notifications)** — `P2` — `missing` — fixed in 8e1d03c (expo-notifications local notifications scheduled from the store for the next 7 days, calendar default alerts honoured, cancelled events / completed tasks / acknowledged alerts skipped)
- What WEB does: `useCalendarAlerts` polls every 60 s, proactively fetches the next 24 h, computes fire times from `alerts` (offset relative to start/end, absolute triggers, calendar default alerts via `useDefaultAlerts`), dedupes through a persisted acknowledged store, plays a sound and toasts; task due alerts too; cancelled events muted (#572) (ref `lib/calendar-alerts.ts:39-201`, `hooks/use-calendar-alerts.ts:21-158`, `stores/calendar-notification-store.ts`).
- What RN does: `src/lib/calendar-alerts.ts` only converts offsets <-> reminder presets (1-103); the `calendarNotificationsEnabled`/`calendarNotificationSound` settings are shown in `NotificationSettings.tsx:52-53, 235-240` but nothing reads them; `package.json` has no `expo-notifications`. Users who set "15 minutes before" get nothing.
- Fix hint: add `expo-notifications`, port `computeFireTime`/`getPendingAlerts`, and schedule local notifications for the next N days on every `fetchEvents` (cancel + reschedule by event id); honour `useDefaultAlerts` via `Calendar.defaultAlertsWithTime/WithoutTime` (add those to `Calendar/get` properties).
### Tasks
- [x] **Task sheet supports only title + due date; no edit, description, priority, due time, progress states, alerts, filters, "show on calendar"** — `P2` — `partial` — fixed in 09c24fa (edit, description, priority, due date + time, calendar move, all/pending/completed/overdue filters, overdue-first sort, `needs-action` <-> `completed` with `progressUpdated`, due tasks overlaid on the grid; task alerts still come only from the server data)
- What WEB does: task modal with description, due date + optional time, priority (none/high/medium/low -> 1-9), progress, calendar, alert; list filters all/pending/completed/overdue, overdue-first sort, `showTasksOnCalendar` overlays due tasks on the grid; completion toggles `needs-action` <-> `completed` with `progressUpdated` (ref `components/calendar/task-modal.tsx:55-140`, `task-list-view.tsx:80-110`, `stores/task-store.ts:366, 442-452`).
- What RN does: `TasksSheet` creates `{ title, progress, due }` only, no tap-to-edit, delete + toggle only (`src/components/calendar/TasksSheet.tsx:117-135, 210-235`); un-completing sets `in-process` (`src/stores/calendar-store.ts:418-426`); `showTasksOnCalendar` (`settings-store.ts:174`) is unused; tasks are never drawn in month/week/agenda.
- Fix hint: add a task edit sheet mirroring `task-modal.tsx`, filter chips, and render due tasks as all-day chips when `showTasksOnCalendar`.
### Settings
- [ ] **No custom time-zone setting (#755); no birthday-calendar colour; no "Day" view on mobile (see above)** — `P3` — `missing` — time-zone setting (`calendarTimeZone`, used for saved events and JMAP queries) done in 58a6f45 + 0f80e98; deferred: birthday-calendar colour setting
- What WEB does: `timeZone: 'auto' | IANA` overriding browser detection, used for display (`displayNow`, `toDisplayDate`) and for the `timeZone` on saved events (ref `lib/timezone.ts:29-75`, `stores/settings-store.ts:317, 553`, changelog 1.9.2 #755); `birthdayCalendarColor` (`settings-store.ts:377, 609`).
- What RN does: device zone only (`src/api/calendar.ts:15-21`); birthday colour fixed (`src/lib/birthday-calendar.ts:7`).
- Fix hint: add `timeZone` to the settings store (synced with WEB's key so it round-trips through settings sync), pass it as the JMAP `timeZone` arg and into saved events; convert display via `Intl` like WEB's `getWallClock`.
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Client-side recurrence expansion: `src/lib/recurrence-expansion.ts` is a faithful port of `lib/recurrence-expansion.ts` (byX filtering, bySetPosition, RDATE overrides, fast-forward, per-occurrence `utcStart`/`utcEnd` #116); only the server-instance guard differs.
- Stalwart singular `recurrenceRule`/`excludedRecurrenceRule` normalisation on read and write (#13, JSCalendar 2.0) — `src/api/calendar.ts:64-126`.
- Singular `inCalendar` OR-filter, `timeZone` on query/get, batched `CalendarEvent/get` (#141) — `src/api/calendar.ts:30-38, 210-231`.
- Shared calendars via other session accounts, namespaced ids (`${accountId}:${id}`), `originalId`, event remapping, per-account query routing, best-effort failure isolation — `src/api/calendar.ts:140-186`, `src/stores/calendar-store.ts:35-58, 170-204`.
- Per-viewer colours for shared calendars with auto-assigned unused palette colour (#345) — `src/screens/CalendarScreen.tsx:181-211`, `src/lib/calendar-utils.ts:465-511`.
- Tasks-only calendars hidden from event UI and pickers (#28 / 9c084d9d) — `CalendarScreen.tsx:227-242`.
- Set default calendar via `onSuccessSetIsDefault` — `src/api/calendar.ts:403-413`.
- Malformed `utcStart`/`start` fallback (#316) — `src/lib/calendar-utils.ts:35-56`; undefined alert trigger guard (#143) — `src/lib/calendar-alerts.ts:57-60`.
- Multi-day all-day bars aligned across the week, timed full-day promotion, cluster-based overlap layout — `src/lib/calendar-utils.ts:240-420`, `WeekView.tsx:69-101`.
- Start move keeps duration (`EventModal.tsx:244-248`), Save disabled until title, long-press slot/day creates at the tapped time (7e9aeaf0 equivalent).
- Server-sent iMIP via `sendSchedulingMessages` only (no client-side duplicate emails); import uses no scheduling (#411) — `src/api/calendar.ts:233-297`.
- RSVP via RFC 6901-escaped `participants/<id>/participationStatus` — `src/api/calendar.ts:331-352`.
- Birthday calendar generation incl. Feb 29 clamp and age — identical to WEB (`diff` clean apart from the extra WEB null fields).
- iCal import with preview, select all/none, 5 MB cap, UID dedupe — `ICalImportSheet.tsx`.
- Push StateChange refresh for `Calendar`/`CalendarEvent` on primary + shared accounts — `App.tsx:390-397`, `src/stores/calendar-store.ts:261-284`.
- Calendar tab hidden when the account lacks the JMAP calendars capability — `App.tsx:95, 160`, `src/lib/capabilities.ts:25`.
- Week start setting, week numbers in month view, 12h/24h time format threaded through views.
## N/A on mobile
- Mouse drag to move/resize, click-drag create, 15-minute snapping, top-edge resize, hover preview (`hooks/use-time-grid-interactions.ts`) — long-press create is the mobile equivalent.
- Keyboard shortcuts (t/m/w/d/a/k/n, arrows), right-click context menus (long-press could carry the same actions, see "Duplicate/export" finding).
- Pro multi-account aggregation (`hooks/use-pro-multi-account-calendars.ts`, `localAccountId::` namespacing, `reconcileSelectedIds`) — RN is single-active-account.
- `/api/fetch-ical` server-side SSRF guard / DNS pinning (GHSA-24w9) — no server component in RN.
- `webcal:` / `mailto:` protocol-handler registration and import-or-subscribe prompt for detected webcal links.
- Agenda plugin sidecar, plugin event-action slot (Jitsi), Pro tab deep-link URL writing.
- Refresh-gesture interception (F5/Ctrl-R) — RN has pull-to-refresh.
+320
View File
@@ -0,0 +1,320 @@
# Contacts & address books
## Summary
RN covers the visible surface reasonably well (list with alphabetical index, detail screen with every RFC 9553 field block, a full edit form, groups with member management, address-book CRUD, vCard import/export, trusted-senders book, contact activity). The gaps are mostly below the UI: the RN JMAP layer (`src/api/contacts.ts`) ignores `notCreated/notUpdated/notDestroyed`, never assigns a UID (#644), does not re-fetch the created card (so a freshly saved contact shows as a blank row), does not paginate `ContactCard/query`, and has no first-touch gate (#907). The RN vCard parser is a stale fork of the web one: it predates #224 (dates are imported as raw strings, which Stalwart drops), lacks every X- vendor extension, Apple grouped labels, and org-only cards (#701). Composer autocomplete is contacts-only (no groups, no recent recipients, no directory), there is no "add sender to contacts" entry point from the viewer, no way to create a group, no shared/group-account address books, and the trusted-senders settings screen cannot show or remove entries that live in the synced book.
## Findings
### JMAP / store layer
- [x] **Newly created contact appears as a blank row until refresh** — `P2` — `bug` — fixed in 580e010
- What WEB does: `client.createContact` reads the server-assigned id from `created` and then re-fetches the full card with `ContactCard/get` before returning it (`lib/jmap/client.ts:5128-5133`), so the store appends a complete card.
- What RN does: `createContact` returns `res.methodResponses[0][1].created['new-contact']` verbatim (`src/api/contacts.ts:73`) which per JMAP only carries server-set properties (id, created, updated…); the store appends that stub (`src/stores/contacts-store.ts:170-174`). `ContactFormScreen.handleSave` just `goBack()`s (`src/screens/ContactFormScreen.tsx:631-633`) so the list shows an "Unnamed" row with no email until a push/pull refresh; `doDuplicate` even navigates to that stub (`src/screens/ContactDetailScreen.tsx:231-232`).
- Fix hint: in `api/contacts.ts` merge `{ ...contact, addressBookIds, ...created }` or issue a follow-up `ContactCard/get` for the created id (mirror WEB) before returning.
- [x] **`ContactCard/set` errors are swallowed or crash with a TypeError** — `P2` — `bug` — fixed in 580e010
- What WEB does: every set call checks `notCreated` / `notUpdated` / `notDestroyed` and throws the server `description` (`lib/jmap/client.ts:5122-5126, 5155-5160, 5180-5184`); the method-level `error` response is also handled.
- What RN does: `createContact` indexes `.created['new-contact']` without checking `created`/`notCreated` (`src/api/contacts.ts:73`) so a rejected card surfaces as "Cannot read property 'new-contact' of undefined"; `updateContact` (`:76-85`) and `deleteContacts` (`:87-93`) never look at `notUpdated`/`notDestroyed`, so the store applies the optimistic change (`src/stores/contacts-store.ts:176-195`) although the server refused it (e.g. read-only shared book, invalid PartialDate).
- Fix hint: route all three through `methodResult()` (already defined at `src/api/contacts.ts:7-15`) and throw on `notCreated/notUpdated/notDestroyed[id].description`; `bulkDelete` should only drop the ids that were actually destroyed.
- [x] **No UID assigned on contact creation (#644)** — `P2` — `bugfix-parity` — fixed in 580e010
- What WEB does: sends `uid: contactData.uid || 'urn:uuid:<uuid>'` on every create because Stalwart stores the card without one otherwise (`lib/jmap/client.ts:5113-5116`, changelog 1.6.x "Assign a UID to contact cards on creation").
- What RN does: `createContact` forwards the card as-is (`src/api/contacts.ts:65-70`); form, import, duplicate and trusted-sender creation all go through it. Group membership then falls back to the raw id (`src/screens/GroupDetailScreen.tsx:90`), and exported vCards/CardDAV clients get UID-less cards.
- Fix hint: add `uid: contact.uid || \`urn:uuid:${generateUUID()}\`` in `api/contacts.ts createContact` (RN already has `src/lib/uuid.ts`).
- [x] **Contact list silently truncated at the query limit (no pagination)** — `P2` — `bug` — fixed in 580e010
- What WEB does: `fetchPaginatedContacts` walks `ContactCard/query` with `position`/`limit = maxObjectsInGet` until `total` is reached, then batches `ContactCard/get` (`lib/jmap/client.ts:~4985-5022`).
- What RN does: a single `ContactCard/query` with `limit: 1000` (`src/api/contacts.ts:26-40`); the server may clamp the limit lower and anything beyond is never fetched. `getContacts` batches the get correctly (`:42-57`).
- Fix hint: loop on `position` until `ids.length < limit || total reached` in `queryContacts`, mirroring WEB.
- [x] **No first-touch gate: concurrent AddressBook/get + ContactCard/query on cold start (#907)** — `P2` — `bugfix-parity` — fixed in 26a34d5
- Store-side: `fetchAddressBooks`/`fetchContacts` are single-flight and contacts wait for an in-flight AddressBook/get; the request-level `FirstTouchGate` in `src/api/jmap-client.ts` was added by the client agent.
- What WEB does: `FirstTouchGate` serialises the first request per `(collection, account)` so Stalwart's lazy default-collection creation cannot race across cluster nodes (`lib/jmap/first-touch-gate.ts:1-101`, wired at `lib/jmap/client.ts:678,1129`; changelog 1.9.x "Gate first-touch calendar and contacts requests to stop duplicate default calendars (#907)").
- What RN does: `ContactsScreen` fires `fetchAddressBooks()` and `fetchContacts()` in the same effect (`src/screens/ContactsScreen.tsx:120-123`) and `restoreSession` fans out `fetchContacts` + `fetchCalendars` (`src/stores/auth-store.ts:91-93`) with no gate in `src/api/jmap-client.ts` (grep for gate/firstTouch: none). Against a multi-node Stalwart this can mint a duplicate default address book / calendar.
- Fix hint: port `first-touch-gate.ts` into `src/api/` and wrap `jmapClient.request` (key on `AddressBook/`, `ContactCard/`, `Calendar/`, `CalendarEvent/` + accountId); reset it on logout/account switch.
- [x] **Contacts account id: RN uses the mail primary account, not `primaryAccounts[contacts]`** — `P3` — `partial` — fixed in 580e010
- What WEB does: `getContactsAccountId()` prefers `primaryAccounts["urn:ietf:params:jmap:contacts"]` (`lib/jmap/client.ts:4677-4680`).
- What RN does: every call uses `jmapClient.accountId`, resolved from the mail/core primary (`src/api/contacts.ts:18,32,44,63`; `src/api/jmap-client.ts:388-399`).
- Fix hint: add `getContactsAccountId()` to the RN client and use it in `api/contacts.ts`.
- [x] **Shared / group-account address books not listed or writable** — `P2` — `missing` — fixed in 26a34d5
- Drawer "Shared from X" sections in 358bdb9; writes are routed to the owning account via `originalId`/`accountId`.
- What WEB does: `getAllAddressBooks`/`getAllContacts` iterate every contacts-capable or non-personal account, namespace ids as `accountId:bookId`, tag `isShared/accountName` (`lib/jmap/client.ts:4718-4733, 4756-4793, 5036-5070`); sidebar renders "Shared from X" sections (`components/contacts/contacts-sidebar.tsx:160-176, 562-603`); create/update/move de-namespace ids and pick the right account (`stores/contact-store.ts:473-527, 529-561, 926-978`); changelog 1.8.x "Require an explicit shared-account fallback for contacts and calendars".
- What RN does: single-account only (`src/api/contacts.ts` throughout); `AddressBook` type has no `accountId/isShared` (`src/api/types.ts:369-377`).
- Fix hint: iterate `session.accounts` where `accountCapabilities[contacts]` or `!isPersonal` (as WEB `getContactCapableAccountIds`), namespace ids, and render a "Shared from" section in `ContactsSidebarDrawer`; route writes to the owning account. The `myRights.mayWrite` checks already present in `AddressBookPickerSheet:96` and `ContactsSettings:204,209` will then become meaningful.
- [x] **Deleting a contact does not clean group member references locally** — `P3` — `partial` — fixed in 26a34d5
- Drawer member counts resolve through `selectGroupMembers` in 358bdb9.
- What WEB does: `cleanGroupMembers` strips the deleted card's id/uid/`urn:uuid:` variants from every group's `members` in state on delete/bulk delete (`stores/contact-store.ts:339-366, 571-578, 911-918`).
- What RN does: `deleteContact`/`bulkDelete` just filter the list (`src/stores/contacts-store.ts:185-195`); group counts in the drawer use raw `Object.keys(g.members).length` (`src/components/contacts/ContactsSidebarDrawer.tsx:150`) so they keep counting deleted members until the next full refresh.
- Fix hint: port `cleanGroupMembers`; resolve drawer counts through `selectGroupMembers` like WEB `memberCountByGroup` (`contacts-sidebar.tsx:250-270`).
- [x] **Deleting an address book leaves its contacts in the cache** — `P3` — `partial` — fixed in 26a34d5
- What WEB does: `removeAddressBook` drops contacts filed in that book from state (`stores/contact-store.ts:1053-1056`).
- What RN does: strips the book id but keeps the cards (`src/stores/contacts-store.ts:260-276`), so they show under "All"/"Uncategorized" until a refresh; the confirm text ("will be removed from this book") also implies they survive (`src/components/settings/ContactsSettings.tsx:282`).
- Fix hint: filter out cards whose only book was the deleted one, or call `fetchContacts()` after the destroy.
- [x] **Persisting every contact card (incl. base64 photos) to AsyncStorage** — `P3` — `rn-only-bug` — fixed in 26a34d5
- `media` is stripped from the persisted snapshot; picked photos are downscaled to 512px JPEG with `expo-image-manipulator` in 9800ad0.
- What RN does: `partialize` persists `contacts` and `addressBooks` (`src/stores/contacts-store.ts:355-366`); cards carry inline `data:` photos (`src/screens/ContactFormScreen.tsx:690-691` stores the picked image as base64 with no downscale). Android AsyncStorage's default 6 MB cap will make the write fail for a few hundred photo contacts and the cache silently stops updating.
- Fix hint: strip `media` from the persisted snapshot (re-hydrate photos from the server) and/or downscale photos on pick (WEB caps at 512px JPEG q0.85, `components/contacts/contact-form.tsx:96-126`).
### Address books
- [x] **Set default address book missing** — `P3` — `missing` — fixed in bc4c57e
- `setDefaultAddressBook` (onSuccessSetIsDefault) and the `isDefault`-free update patch landed in 580e010; new contacts/imports prefer the default book via `getDefaultAddressBookId`.
- What WEB does: context-menu "Set as default" uses `AddressBook/set onSuccessSetIsDefault` (`components/contacts/contacts-sidebar.tsx:612-663`, `lib/jmap/client.ts:4851-4869`, `stores/contact-store.ts:1017-1044`), and the create form preselects the default book (`components/contacts/contact-form.tsx:362-367`).
- What RN does: no UI; `updateAddressBook` even forwards `isDefault` as a patch property (`src/api/contacts.ts:117,122`) which WEB notes is read-only and fails the whole update (`lib/jmap/client.ts:4819-4821`). New contacts/imports default to `addressBooks[0]` (`src/screens/ContactFormScreen.tsx:581,591-593`; `src/screens/ContactsScreen.tsx:201-204`; `src/components/settings/ContactsSettings.tsx:54`).
- Fix hint: add `setDefaultAddressBook` (onSuccessSetIsDefault) to `api/contacts.ts` + a row action in `ContactsSettings`; drop `isDefault` from the update patch; prefer `books.find(b => b.isDefault)` as the default target.
- [x] **Default address book can be offered for deletion** — `P3` — `partial` — fixed in bc4c57e
- What WEB does: hides delete for `isDefault`/shared books (`components/contacts/contacts-sidebar.tsx:615`).
- What RN does: shows the trash icon whenever `books.length > 1 && mayDelete !== false` (`src/components/settings/ContactsSettings.tsx:209`); the server then rejects and the user sees a raw error.
- Fix hint: add `!book.isDefault` to the guard.
- [ ] **Address book share / visibility (subscription) management missing** — `P3` — `missing`
- deferred: `shareWith`/`myRights` are now requested explicitly (580e010) and typed on `AddressBook`; no sharing sheet on mobile yet (reuse the calendar-sharing sheet when it exists).
- What WEB does: `shareAddressBook` via `shareWith/<principal>` patch + ShareCollectionDialog (`stores/contact-store.ts:1064-1085`, `components/contacts/contacts-app.tsx:1142-1158`); sidebar shows a shared indicator (`contacts-sidebar.tsx:879-881`).
- What RN does: nothing; `getAddressBooks` requests no `properties` so `shareWith` is whatever the server defaults to (`src/api/contacts.ts:17-24`; WEB requests `ADDRESS_BOOK_PROPERTIES` incl. `shareWith`, `lib/jmap/client.ts:252-261`, #257).
- Fix hint: low priority on mobile; if added, reuse whatever calendar-sharing sheet the calendar agent proposes and request `properties` explicitly.
### Contact card / form
- [x] **Clearing a whole field group on edit never reaches the server** — `P2` — `rn-only-bug` — fixed in 9800ad0
- What RN does: `formToPatch` only includes keys that are non-empty (`src/screens/ContactFormScreen.tsx:378-395`), and `updateContact` sends the patch as a JMAP `update` (`src/api/contacts.ts:82`). Removing the last phone/address/note/keyword/anniversary/online link of an existing contact therefore omits the key, which JMAP treats as "unchanged"; the store merge (`contacts-store.ts:178-182`) also keeps the old value, so the UI shows the removed items again.
- Fix hint: in edit mode emit `null` for every collection the form owns that ended up empty (emails, phones, addresses, organizations, titles, anniversaries, onlineServices, personalInfo, notes, keywords, nicknames, speakToAs, calendarUri…) — the same approach WEB uses for `media: null` (`components/contacts/contact-form.tsx:555-558`).
- [x] **Saving the form drops non-photo media (logo, sound)** — `P3` — `rn-only-bug` — fixed in 9800ad0
- What WEB does: copies every non-photo `media` entry through and keeps the original photo key (`components/contacts/contact-form.tsx:544-553`).
- What RN does: rebuilds `media` with only `media.photo` (`src/screens/ContactFormScreen.tsx:359-366,395`), so an imported LOGO/SOUND is deleted on the first edit.
- Fix hint: carry `existing.media` entries with `kind !== 'photo'` into the patch.
- [x] **Anniversary input accepts free text that the server rejects; year-only dates get a fake Jan 1** — `P2` — `rn-only-bug` — fixed in 9800ad0
- `stringToPartialDate` / lossless `partialDateToString` live in `src/lib/contact-utils.ts` (580e010).
- What WEB does: `<input type="date">` plus `stringToPartialDate` always yields a structured PartialDate (`components/contacts/contact-form.tsx:192-206, 525-528`); changelog 1.9.x #224 stresses that Stalwart drops non-PartialDate anniversaries.
- What RN does: `stringToAnniversaryDate` returns the raw string for anything other than `YYYY-MM-DD` / `--MM-DD` (`src/screens/ContactFormScreen.tsx:116-128`), so "1990-05", "May 5" or a `YYYY-MM-DDT…` value is sent as a string → Stalwart rejects the set (and, via the previous finding, the failure is invisible). `partialDateToString` pads a year-only or year-month PartialDate with `01` (`:110`), so merely opening and saving a contact with `{year:1990}` rewrites it to 1990-01-01 — this feeds the birthday calendar with a wrong day.
- Fix hint: reject/normalise input to PartialDate (support `YYYY`, `YYYY-MM`, `--MM-DD`, `---DD` like WEB `parseVcardDate`, `lib/vcard.ts:360-394`), and make `partialDateToString` lossless (`1990`, `1990-05`).
- [x] **Organization-kind cards (#701)** — `P3` — `partial` — fixed in 9800ad0
- Detail subtitle no longer repeats the org name (1cef53a).
- What WEB does: person/organization toggle, org name validates as the identity, `name.full` filled from org, `kind: 'org'` written, detail hides the duplicated org line (`components/contacts/contact-form.tsx:269-277, 443-449, 491-495, 560-563, 696-735`; `components/contacts/contact-detail.tsx:178-180`).
- What RN does: no toggle/kind; validation requires given/surname/full or an email (`src/screens/ContactFormScreen.tsx:607-612`), so an org card needs its name typed into "Display name" and an org-only card without email cannot be created; detail subtitle repeats the org under the heading when they are equal (`src/screens/ContactDetailScreen.tsx:136`).
- Fix hint: add a Person/Organization pill in the Identity section; when org: skip name components, set `name.full = org`, `kind: 'org'`, accept org name as identity; hide the equal subtitle.
- [x] **Photo data URIs from Stalwart not normalised (#307)** — `P2` — `bugfix-parity` — fixed in 580e010
- What WEB does: `normalizeContactPhotoUri` rewrites `data:base64,…` / `data:;base64,…` to `data:image/jpeg;base64,…` in `getContactPhotoUri` and in the form (`stores/contact-store.ts:157-180`; `contact-form.tsx:376`).
- What RN does: `getContactPhotoUri` returns `media.uri` raw (`src/lib/contact-utils.ts:71-77`), used by `ContactListRow:38`, `ContactDetailScreen:114` and the form (`ContactFormScreen.tsx:233-237`); RN `Image` cannot decode a media-type-less data URI so such photos render blank, and re-saving writes the malformed URI back.
- Fix hint: port `normalizeContactPhotoUri` into `contact-utils.ts` and apply in `getContactPhotoUri` + `contactToForm`.
- [x] **Picked photos are stored full-size** — `P3` — `partial` — fixed in 9800ad0
- What WEB does: downscales to 512px JPEG before embedding (`components/contacts/contact-form.tsx:96-126`) and rejects files >10 MB.
- What RN does: `ImagePicker` with `quality: 0.8`, no dimension cap, base64 straight into the card (`src/screens/ContactFormScreen.tsx:680-695`).
- Fix hint: use `expo-image-manipulator` to resize to ≤512px before reading base64.
- [x] **Duplicate contact keeps the source UID** — `P2` — `rn-only-bug` — fixed in 1cef53a
- What WEB does: strips `id`, `uid`, `created`, `updated` before creating the copy (`components/contacts/contacts-app.tsx:448-451`).
- What RN does: `doDuplicate` only strips `id/addressBookIds/created/updated` (`src/screens/ContactDetailScreen.tsx:216-222`), so the copy is created with the same `uid`; group membership by uid then matches both cards and CardDAV/vCard consumers see two cards with one UID.
- Fix hint: also drop `uid` (and let `createContact` mint a fresh one once the #644 fix lands).
- [x] **Gender vocabulary differs from WEB and from the vCard exporter** — `P3` — `partial` — fixed in 9800ad0
- What WEB does: `masculine | feminine | other | none | unknown` (vCard SEX mapping, `components/contacts/contact-form.tsx:1062-1069`), which `grammaticalGenderToVcardSex` understands (`lib/vcard.ts:34-48`).
- What RN does: `masculine | feminine | common | neuter | animate | inanimate` (`src/screens/ContactFormScreen.tsx:437-445`); values other than masculine/feminine export as an empty GENDER and display differently across clients.
- Fix hint: align option lists (or accept both on display) so the same card renders the same value in both apps.
- [x] **Online service `label` and multiple nicknames not editable** — `P3` — `partial` — fixed in 9800ad0
- Detail shows every nickname (1cef53a).
- What WEB does: online-service entries carry `label` (`contact-form.tsx:24-28, 517-523`); detail shows all nicknames (`contact-detail.tsx:194-196`).
- What RN does: `OnlineDraft` has only `uri/service` (`src/screens/ContactFormScreen.tsx:42, 332-338`), and the form/detail read only the first nickname (`:168-170, 269-270`; `src/lib/contact-utils.ts:171-174`). Saving a card with several nicknames collapses them to one.
- Fix hint: keep unknown nicknames when rebuilding `nicknames`, add a label field to the online row.
- [x] **Display name includes the middle name (differs from WEB sorting/grouping)** — `P3` — `partial` — fixed in 580e010
- What WEB does: `given + surname` only, then `name.full`, nickname, org, email (`stores/contact-store.ts:125-150`).
- What RN does: `given + middle + surname` (`src/lib/contact-utils.ts:13-14`). Harmless on its own but sort order, dedupe keys and `#672`-style name sanitising diverge between clients.
- Fix hint: drop `middle` from the join or make it a display-only concern.
### List, search, sort
- [x] **Alphabetical index buckets every non-ASCII initial under "#"** — `P3` — `bug` — fixed in 358bdb9
- What WEB does: `\p{L}` test + `Intl.Collator(locale, sensitivity base)` so Ä/É/Ł get their own sections (`components/contacts/contact-list.tsx:242-262`).
- What RN does: `/[A-Z]/.test(letter)` (`src/screens/ContactsScreen.tsx:53-54`), so "Éric" and "Ölçer" land in "#".
- Fix hint: use `/\p{L}/u` and `localeCompare` (Hermes supports Unicode property escapes).
- [x] **"Uncategorized" means "no address book" in RN but "no category" in WEB, and its count is hard-coded 0** — `P3` — `bug` — fixed in 358bdb9
- `selectUncategorized` (no keywords) in 26a34d5; the drawer row moved under Tags as "No category" with a live count.
- What WEB does: "No category" = contacts without keywords, with a live count (`components/contacts/contacts-sidebar.tsx:246-248, 526-541`; `contacts-app.tsx:283-285`).
- What RN does: filter = contacts with no `addressBookIds` (`src/stores/contacts-store.ts:439-440`; `src/screens/ContactsScreen.tsx:146-149`), drawer row passes `count={0}` (`src/components/contacts/ContactsSidebarDrawer.tsx:128-136`) so the count never shows; on Stalwart every card has a book so the view is always empty.
- Fix hint: redefine as "no keywords", move the row under the Tags section, compute the count.
- [ ] **Contact list filters (org / title / location / domain / birthday month / has email-phone-photo) missing** — `P3` — `missing`
- deferred: optional; free-text search only.
- What WEB does: filter drawer with tri-state chips (`components/contacts/contact-list.tsx:18-76, 154-228, 350-456`; changelog 1.5.x "Revamp contact detail view with filters").
- What RN does: free-text search only (`src/lib/contact-utils.ts:216-241`).
- Fix hint: optional; a filter sheet reusing the WEB predicate block would do.
- [x] **No select-all / bulk export in multi-select** — `P3` — `partial` — fixed in 358bdb9
- What WEB does: select-all toggle, range select, bulk export, bulk add-to-group (`components/contacts/contact-list.tsx:283-305, 458-502`).
- What RN does: long-press multi-select with delete / move / tag only (`src/screens/ContactsScreen.tsx:164-226, 282-313`).
- Fix hint: add "Select all visible" and "Export selected" (reuse `contactsToVCard` + share flow from `ContactsSettings:56-78`).
- [x] **Rename category (keyword) missing** — `P3` — `missing` — fixed in 358bdb9
- `renameKeyword` in the store (26a34d5); long-press a tag in the drawer to rename.
- What WEB does: keyword context menu → rename, rewrites `keywords` on every affected card (`components/contacts/contacts-sidebar.tsx:680-698`; `stores/contact-store.ts:1087-1116`).
- What RN does: tags can be assigned/removed per contact and in bulk (`TagAssignSheet`, `contacts-store.ts:213-235`) but not renamed.
- Fix hint: long-press on a Tags row in `ContactsSidebarDrawer` → prompt → loop `updateContact` like WEB `renameKeyword`.
- [x] **Add-to-group from the contact itself missing** — `P3` — `partial` — fixed in 1cef53a
- What WEB does: detail "More" menu and list context menu offer "Add to group" (creating a group if none) (`components/contacts/contacts-app.tsx:438-446`; `contact-detail.tsx:150-152`).
- What RN does: membership is only editable from `GroupDetailScreen` (`src/screens/GroupDetailScreen.tsx:86-98`); the detail "More" sheet has Duplicate/Move/Export/Delete only (`src/screens/ContactDetailScreen.tsx:247-270`).
- Fix hint: add a "Add to group" item that opens a group picker (list of `selectGroups`) and calls the same `members` patch as `GroupDetailScreen.addMembers`.
### Groups
- [x] **No way to create a contact group** — `P2` — `missing` — fixed in 9800ad0
- "New group" row in the drawer and long-press on the header "+" (358bdb9); `createGroup`/`memberIds` route param.
- What WEB does: "+" menu → "Create group" with name + member picker (`components/contacts/contacts-sidebar.tsx:299-305`; `contact-group-form.tsx`; `stores/contact-store.ts:734-760`).
- What RN does: `ContactForm` supports `asGroup` (`src/screens/ContactFormScreen.tsx:554,627`) but the only caller passes an existing `contactId` for editing (`src/screens/GroupDetailScreen.tsx:138`; grep `asGroup` in `src/`: no other call site). The Contacts header "+" always opens a plain new-contact form (`src/screens/ContactsScreen.tsx:336-343`).
- Fix hint: add "New group" to the header/drawer that navigates to `ContactForm { asGroup: true }` with a members picker (`ContactPickerSheet`), or a dedicated small screen that creates `{ kind:'group', name:{components:[{kind:'given', value}]}, members }`.
- [x] **Group edit reuses the full person form (name split into first/last, photo, emails…)** — `P3` — `partial` — fixed in 9800ad0
- What WEB does: dedicated group form with name + searchable member checklist (`components/contacts/contact-group-form.tsx:80-196`).
- What RN does: `GroupDetailScreen` → `ContactForm asGroup` shows Prefix/First/Middle/Last/Nickname, Email, Phone… (`src/screens/ContactFormScreen.tsx:747-813`); a group named "Sales Team" appears in "First name".
- Fix hint: when `asGroup`, render a single "Group name" field bound to `given` and hide the person-only sections.
- [x] **Group compose: RN expands to individual recipients; no To/Cc/Bcc choice** — `P3` — `partial` — fixed in 1cef53a
- Dedupe via `getGroupRecipients` (26a34d5); a single group chip is up to the composer agent.
- What WEB does: hands the composer one expandable group chip (RFC 5322 group syntax) with a To/Cc/Bcc submenu (`components/contacts/contacts-app.tsx:561-587`; `contact-group-detail.tsx:69-87`; changelog 1.7.x "Contact groups as single expandable recipient chips").
- What RN does: "Email all" pushes one recipient per member into `prefillTo` (`src/screens/GroupDetailScreen.tsx:72-84`). Functionally fine; dedupe by email is missing (two members sharing an address are both added).
- Fix hint: dedupe case-insensitively; a group chip is optional unless the composer agent adds group chips.
### Composer autocomplete
- [ ] **Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals** — `P2` — `missing`
- store-side done in 26a34d5 (`getAutocomplete` with group entries + recent recipients, `loadRecentRecipients`/`searchRecipients` fed by `src/api/recent-recipients.ts`, `getGroupRecipients`, #672 normalisation); wiring `ComposeScreen`/`ParticipantInput` belongs to the composer agent; directory principals not ported.
- What WEB does: `getAutocomplete` merges contacts, group entries (as one chip with `memberCount`), RFC 9670 directory principals and recent Sent recipients, deduped and sanitised (#672) (`stores/contact-store.ts:622-707, 23-49`; `loadRecentRecipients` `:1118-1142` fed from `mail-app.tsx:423`; `searchRecipients` `:1144-1154` used by `components/email/email-composer.tsx:1175-1212`; group chip insertion `:1218-1233`; changelogs 1.6.x "Recipient autocomplete from Sent, with on-demand server search", 1.7.x group chips).
- What RN does: suggestions come only from `individuals` matched with `matchesContactSearch` (`src/screens/ComposeScreen.tsx:367-392`); groups are explicitly filtered out and nothing else is consulted. `ParticipantInput` (calendar) likewise flattens contacts only (`src/components/calendar/ParticipantInput.tsx:34-51`).
- Fix hint: add `recentRecipients` (scan Sent `to/cc`, 300 msgs) to the contacts store, include groups as a pickable entry that expands to member emails on pick, and reuse `sanitizeDisplayName/splitMailbox`-style cleanup for names that contain a mailbox (#672).
### Viewer integration
- [ ] **"Add sender to contacts" from the message view missing** — `P2` — `missing`
- contact-side done: `ContactForm { prefill }` seeds email + given/surname (9800ad0), `findContactByEmail` in the store (26a34d5); the sender sheet in `EmailThreadScreen` belongs to the viewer agent.
- What WEB does: contact sidebar/popover on sender click with "Add to contacts" that creates a card with given/surname split from the display name (`components/email/email-viewer.tsx:5350-5366, 558-564`); also the mobile recipient popover (#306) and `/contacts/new?addEmail=` deep link (`components/contacts/contacts-app.tsx:182-198`).
- What RN does: no contact lookup or "add" action in `EmailThreadScreen` (grep contact/ContactForm: only `SenderAvatar` at `:794`). Only `ContactForm`'s `route.params` support `contactId/addressBookId/asGroup` (`src/navigation/types.ts:24`) — no email/name prefill.
- Fix hint: add `prefill?: { email; name }` to the `ContactForm` route, and a sender-tap sheet in `EmailThreadScreen` with "Add to contacts" / "Open contact" (lookup by email in `useContactsStore`).
- [ ] **Sender-name tap → contact popup missing** — `P3` — `missing`
- deferred: viewer agent (same sheet as above; `findContactByEmail` + `ContactDetail { contactId }` are ready).
- What WEB does: clicking the sender name opens the contact popup with details/actions (changelog 1.4.x "Show contact popup when clicking the sender name", `email-viewer.tsx:392-455`).
- What RN does: none (see previous item).
- Fix hint: same sheet as above; show the matching card's phone/org and an "Open" that navigates to `ContactDetail`.
- [ ] **Contact deep links (`/contacts/<id>[/edit]`, `/contacts/new`)** — `P3` — `missing`
- deferred: navigation/deep-link agent; contact routes already exist.
- What WEB does: `parseContactsPath`/`buildContactsPath` (`lib/deep-links.ts:323-329`), applied once on mount (`components/contacts/contacts-app.tsx:182-224`).
- What RN does: no `linking` config anywhere (grep `prefixes|LinkingOptions|getInitialURL` in `App.tsx`/`src`: none). Cross-reference the navigation/deep-link agent; contact-side requirement is just `ContactDetail { contactId }` / `ContactForm { contactId }` which already exist (`src/navigation/types.ts:23-24`).
### vCard import / export (`lib/vcard.ts` vs `src/lib/vcard.ts`)
- [x] **Imported dates are raw strings, not RFC 9553 PartialDate (#224) — birthdays vanish on Stalwart** — `P1` — `bugfix-parity` — fixed in e650cb2
- What WEB does: `parseVcardDate` converts BDAY/ANNIVERSARY/DEATHDATE/X-ABDATE (basic + extended forms, reduced accuracy, `VALUE=text` skipped) into `{year, month, day}` and drops undecodable dates (`lib/vcard.ts:360-394`, `addAnniversary` in `buildContact` ~`:480-490`; changelog 1.9.x "Import vCard dates as RFC 9553 PartialDate and map common X- extensions (#224)").
- What RN does: `card.anniversaries.a0 = { kind: 'birth', date: val }` with the raw string (`src/lib/vcard.ts` BDAY case ~`:687`, ANNIVERSARY ~`:701`, DEATHDATE ~`:709`), so every imported birthday is rejected by Stalwart (the whole `ContactCard/set` create fails with invalidProperties, and because of the error-handling finding above the failure is reported only as a generic "import failed for one contact" warning) — i.e. importing a vCard with a BDAY loses the entire contact, not just the date.
- Fix hint: copy `parseVcardDate` + `addAnniversary` from WEB into RN `vcard.ts`; add a test with `BDAY:19850412`, `BDAY:--0412`, `BDAY;VALUE=text:circa 1800`.
- [x] **Vendor X- extensions not imported (X-ABDATE, X-ABRELATEDNAMES, X-SPOUSE/MANAGER/ASSISTANT, X-MAIDENNAME, X-GENDER, X-ANDROID-CUSTOM, X-AIM/X-SKYPE/… IM handles)** — `P2` — `bugfix-parity` — fixed in e650cb2
- What WEB does: `X_ONLINE_SERVICES` map → onlineServices, `RELATION_LABELS`/`ANDROID_RELATION_TYPES` → relatedTo, `ANDROID_EVENT_KINDS` → anniversaries, X-MAIDENNAME → `surname2`, X-GENDER → grammaticalGender (`lib/vcard.ts:250-323, ~1009-1100, ~1113-1120`); export writes `X-MAIDENNAME` and `X-ABDATE` for `other` anniversaries (`~1173-1178, ~1280-1283`).
- What RN does: `default: break` (`src/lib/vcard.ts:~858`); none of the maps exist (function list: no `X_ONLINE_SERVICES`, `RELATION_LABELS`, `ANDROID_*`). Export skips `surname2` and `other` anniversaries (`~:1025-1045`).
- Fix hint: port the maps and the extension cases verbatim; both files otherwise share structure so a near-mechanical diff apply works.
- [x] **Apple grouped labels (`item1.TEL` + `item1.X-ABLABEL`) are lost** — `P2` — `bugfix-parity` — fixed in e650cb2
- What WEB does: `splitGroupPrefix` keeps the group, `collapseProperties` folds a sibling `X-ABLABEL` into an `X-ABLABEL=` param, `decodeAppleLabel` strips `_$!<…>!$_` (`lib/vcard.ts:116-140, 413-434`); labels then land on emails/phones/addresses/online services.
- What RN does: `stripGroupPrefix` discards the group (`src/lib/vcard.ts:120-131`) and properties are keyed by `keyPart` only (`:334-339`), so iOS/macOS exports lose every custom label and the `X-ABLABEL` lines are silently dropped (`:~854`).
- Fix hint: port `splitGroupPrefix` + `collapseProperties` + `RawProperty` list handling in `parseVCard`.
- [x] **Org-only vCards are dropped on import and exported without FN (#701)** — `P2` — `bugfix-parity` — fixed in e650cb2
- What WEB does: `hasOrg` counts as identity in `buildContact` (`lib/vcard.ts:~1123-1127`); `generateSingleVCard` falls back to the org name for the mandatory FN (`~:1158-1162`).
- What RN does: `if (!hasName && !hasEmail && card.kind !== 'group') return null` (`src/lib/vcard.ts:~893-895`) so a company card with only ORG+TEL is skipped; export emits no `FN`/`N` when there is no personal name (`~:932-936`), producing an invalid vCard (FN is required by RFC 6350) that the RN importer itself would then reject.
- Fix hint: apply the two WEB lines.
- [x] **Only one NICKNAME survives import** — `P3` — `bugfix-parity` — fixed in e650cb2
- What WEB does: `card.nicknames[\`n${idx}\`]` (`lib/vcard.ts` NICKNAME case).
- What RN does: `card.nicknames.n0 = { name: val }` overwrites (`src/lib/vcard.ts` NICKNAME case ~`:548`).
- Fix hint: index like WEB.
- [x] **Import target defaults to the first book and cannot be changed from Settings** — `P3` — `partial` — fixed in bc4c57e
- What WEB does: `client.createContact` without `addressBookIds` files into the `isDefault` book (`lib/jmap/client.ts:5095-5101`).
- What RN does: `ContactsSettings` passes `books[0]` with no `onChangeTarget` (`src/components/settings/ContactsSettings.tsx:54, 269-275`); `ContactsScreen` allows changing the target but also starts at `addressBooks[0]` unless a book category is active (`src/screens/ContactsScreen.tsx:201-204, 476, 480-486`).
- Fix hint: prefer `isDefault`; pass `onChangeTarget` in settings too.
- [x] **Import keeps the source UID (fine) but nothing mints one when absent** — covered by the #644 finding above; also `importContacts` swallows per-card failures with only a `console.warn` (`src/stores/contacts-store.ts:206-208`) so "Imported 0 contacts" is the only feedback. `P3` — add a failure count to the result sheet. — fixed in 26a34d5
- The result sheet now shows how many cards failed to import.
### Trusted senders
- [x] **Trusted-senders settings show only the local list; synced book entries are invisible and cannot be removed** — `P2` — `partial` — fixed in 78114b6
- What WEB does: modal shows the address-book entries when the feature is on, adds/removes through `addToTrustedSendersBook`/`removeFromTrustedSendersBook`, and settings show the synced count (`components/trusted-senders-modal.tsx:22-48`; `components/settings/content-senders-settings.tsx:25-40, 77-82`).
- What RN does: `ContentSendersSettings` reads/writes only `settings-store.trustedSenders` (`src/components/settings/ContentSendersSettings.tsx:19-21, 100-144`); `removeFromTrustedSendersBook` exists in the store (`src/stores/contacts-store.ts:323-333`) but has no caller. A sender trusted from the viewer is written to both places (`src/components/EmailBodyView.tsx:644-651`), so deleting it in settings only removes the local copy and the book keeps trusting it (`EmailBodyView.tsx:513-516`).
- Fix hint: in the modal, list `trustedSenders ∪ trustedSenderEmails`, mark book entries, and call `removeFromTrustedSendersBook` (+ local remove) on delete; show the union count.
- [x] **`trustedSendersAddressBook` setting is dead (`false`, no setter, no toggle) — book is consulted unconditionally** — `P3` — `partial` — fixed in 78114b6
- Setting is now `boolean | null`, auto-`true` once the session advertises contacts, with a "Sync trusted senders to address book" toggle. Gating the passive load / write in `EmailBodyView` belongs to the viewer agent.
- What WEB does: `null` until the account proves contacts support, then auto-`true` (`stores/auth-store.ts:380-385`, `stores/settings-store.ts:361,593`); toggle in settings (`content-senders-settings.tsx:77-82`); the book is only loaded/used when enabled (`mail-app.tsx:199-203`, `email-viewer.tsx:1705`).
- What RN does: default `false`, no setter (`src/stores/settings-store.ts:106,239`); `EmailBodyView` loads the book regardless and ORs `trustedSenderEmails` into the trust check (`:480-485, 513-516`); the `addressBookEmails` branch (treats *every* contact as trusted) is gated on the dead flag (`:486-498`) so it never runs.
- Fix hint: either drop the flag and the dead `addressBookEmails` branch, or expose a "Sync trusted senders to address book" toggle that gates the passive load and the `addToTrustedSendersBook` write.
- [x] **Race: trusting a sender while the passive book load is in flight silently fails** — `P2` — `rn-only-bug` — fixed in 26a34d5
- What WEB does: single-flight promise shared by all callers (`stores/contact-store.ts:184-185, 1156-1199`), so `addToTrustedSendersBook` awaits the load and then has a `bookId`.
- What RN does: `loadTrustedSendersBook` returns immediately when `trustedSendersLoading` is true (`src/stores/contacts-store.ts:279`); `addToTrustedSendersBook` then reads `bookId === null` and throws (`:312-317`), which the viewer swallows (`EmailBodyView.tsx:650`). The passive load is kicked off by the same screen a moment earlier (`:483-485`), so the very first "Trust sender" after app start is likely to hit this.
- Fix hint: keep the in-flight promise in a module variable and return it; if the passive load found no book and `createIfMissing` is true, create it after the await.
- [x] **Duplicate "Trusted Senders" books are not consolidated and the pick is non-deterministic (#730)** — `P3` — `bugfix-parity` — fixed in 26a34d5
- Deterministic pick (sorted by id); consolidation not ported.
- What WEB does: sorts matches by id, uses the first, merges the rest and deletes them (`stores/contact-store.ts:1169-1187, 208-245`); `getAddressBooks({throwOnError})` prevents minting on a failed fetch.
- What RN does: `books.find(b => b.name === …)` (`src/stores/contacts-store.ts:283`) — order-dependent; if the web already created a duplicate pair, RN may trust a different subset than web. RN only creates on explicit add (`:284-290`) and `fetchAddressBooks` throws on error, so RN itself does not mint duplicates.
- Fix hint: sort by id before picking; optional consolidation.
- [x] **Trusted-sender card stores no display name; "Name <email>" input not parsed** — `P3` — `partial` — fixed in 26a34d5
- What WEB does: parses `Name <email>` and stores `name.full` (`stores/contact-store.ts:1201-1225`).
- What RN does: lower-cases the whole string and stores `emails` only (`src/stores/contacts-store.ts:308-321`); a `Name <email>` string (e.g. typed in settings) becomes an invalid address.
- Fix hint: reuse the WEB regex.
- [ ] **Auto-trust reply recipients missing** — `P3` — `missing`
- deferred: composer agent; `addToTrustedSendersBook` now accepts `Name <email>` and never races the passive load (26a34d5).
- What WEB does: on reply/replyAll every To/Cc recipient is added to the trusted book (`components/email/email-composer.tsx:2284-2292`; changelog 1.4.x "Auto-add recipients to trusted senders when replying").
- What RN does: `ComposeScreen` has no trusted-sender code (grep `trusted`: none).
- Fix hint: after a successful send in reply mode call `addToTrustedSendersBook` (+ `addTrustedSender`) for each outgoing To/Cc.
### Activity / misc
- [x] **Contact activity: upcoming events come only from the already-loaded calendar cache** — `P3` — `partial` — fixed in 0e51465
- What WEB does: queries the server for the next 365 days (`components/contacts/contact-activity.tsx:133-169`).
- What RN does: filters `useCalendarStore().events` (`src/components/contacts/ContactActivity.tsx:90-91, 127-142`), i.e. only the range the calendar tab happened to load; on a fresh start it is empty.
- Fix hint: call the RN calendar API for `[now, now+365d]` with a participant filter, falling back to the cache.
- [ ] **Print contact** — N/A on mobile (share vCard exists). Listed under N/A.
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Address book list/create/rename/delete with `myRights` checks (`src/components/settings/ContactsSettings.tsx`, `AddressBookPickerSheet`); inline "New address book…" in the move sheet (WEB #415).
- Move contacts between books (single from detail, bulk from list) via `addressBookIds` patch (`contacts-store.ts:237-241`) — RN equivalent of WEB drag-and-drop.
- Group by first letter with sticky headers + settings toggle (`ContactsScreen.tsx:158-162`, `ContactsSettings.tsx:132-137`); sort by display name.
- Search over name/emails/phones/orgs (RN also matches keywords).
- Detail screen renders every WEB field block: emails/phones with contexts+features+labels, addresses (components, flat, timezone), org/units/titles/roles, anniversaries with age, gender/pronouns, languages, personalInfo, online services, calendar URIs, crypto keys, categories, relatedTo, notes, address book names, created/updated; quick actions call/SMS/mail/share; copy via long-press.
- Contact form: prefix/given/middle/surname/suffix, nickname, display name (`name.full`), multiple emails/phones with context + phone feature pills, addresses as RFC 9553 components, multiple orgs (org/department/title/role), anniversaries with native date picker, online services, personalInfo w/ level, gender+pronouns, calendar URIs, categories (chips + suggestions from existing keywords), multiple notes, photo pick/remove, address book pick, dirty-state discard confirm, email regex validation, `media: {}` when photo removed (equivalent of WEB `media: null`).
- `name.full` display fallback (#179), `getContactPrimaryEmail` with `pref`.
- Group detail: member list resolved via id/uid/`urn:uuid:` (`contacts-store.ts:401-415`), add members via `ContactPickerSheet`, remove member, delete group, email all.
- Tags (keywords) = WEB categories: drawer section with counts, filter by tag, bulk assign via `TagAssignSheet`, per-contact edit in form.
- vCard import sheet: file picker, 5 MB cap, parse, duplicate detection by email (`detectDuplicates` identical), select all/none, target book, result count; export all (settings) and single (detail) with share sheet; multi-vCard files; QUOTED-PRINTABLE + charset decode (RN has a Hermes-safe `decodeBytes`), RFC 6868 params, KIND/MEMBER, PHOTO/LOGO/SOUND, RFC 6474/6715/8605/9554 properties.
- Contact activity: recent emails via `from/to` OR filter (limit 5), opens the thread.
- Trusted senders: same book name constant, passive load, add on "Trust sender" in the viewer, `isTrustedAddressBookSender` check.
- Contacts tab hidden when the session lacks `urn:ietf:params:jmap:contacts` (`src/lib/capabilities.ts:29-31`, `App.tsx:170-179`) — WEB 1.8.x "Hide Contacts and Calendars when the account lacks the JMAP capability".
- Push `StateChange` for `AddressBook`/`ContactCard` triggers refetch (`contacts-store.ts:152-168`, `App.tsx:390-396`); contacts cache persisted for instant render; selected category persisted.
- Contact store reset on logout/account switch (`src/stores/auth-store.ts:58,76,113,179,379`) — the RN counterpart of the 583e20d9 namespacing fix (single-account store, so no id-form flip).
## N/A on mobile
- Pro multi-account aggregation (`hooks/use-pro-multi-account-contacts.ts`, `fetchAllAccountsContacts`, `::` id namespacing) — Pro shell only; RN keeps one account active and resets the store on switch.
- Local-storage (non-JMAP) contact fallback (`supportsSync === false`, `local-<uuid>` ids) — RN hides the tab when contacts are unsupported.
- Drag-and-drop to address books / categories, right-click context menus, Ctrl/Shift range selection, resizable panes, keyboard shortcuts — replaced by long-press multi-select + sheets.
- Print contact (`components/contacts/contact-print.ts`).
- Plugin contact APIs and the `contact-cryptokeys` plugin slot.
- `mailto:`/`tel:` handoff differences (RN uses `Linking`).
- Native issue #34 (device contacts / calendar sync adapters) is a feature request, not a parity item; note that a device sync would want the UID (#644) and PartialDate (#224) fixes above first.
+185
View File
@@ -0,0 +1,185 @@
# Filters (Sieve), Vacation responder, Files (FileNode)
## Summary
Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests as of ~1.7.2, so external/Nextcloud/opaque handling, `# Rule:` dedupe, brace-safe parsing and the store logic are at parity. It is missing the 1.7.3 "extended rules" change (multi-value conditions + attachment field + `mime` require); because both clients share one active script, a rule authored on WEB with those features either makes RN fall back to "opaque" mode or, worse, gets silently rewritten as `header :contains "undefined" ...` the next time RN saves anything. RN's FilterRuleModal also never re-seeds its state (always-mounted Modal), so editing a second rule shows and saves the first rule's fields. Vacation: RN has the basic JMAP VacationResponse flow but no HTML body, sends non-UTC `fromDate/toDate`, and has hard-coded English. Files: README's "UI stubs only" is stale - RN has real FileNode browsing (folder = blobId==null), create folder, single-file upload, rename, delete, download/share, RFC 9670 sharing with a principal picker and "shared with me" - but reads the non-existent `updated` property (dates always blank, sort-by-modified is a no-op), does not percent-decode WebDAV-created names, does not reset on account switch, and lacks preview, move/copy/paste/duplicate, favorites/recent, search, upload progress/limits and multi-file upload.
## Findings
### Filters (Sieve)
- [x] **RN generator corrupts WEB-authored attachment rules on any save** — fixed in 1c6aa68 — `P1` — `bug` (missing feature with data-loss side effect)
- What WEB does: `attachment` field with `has_any`/`has_type` comparators emits `header :mime :anychild ...` and adds `mime` to `require` (`lib/sieve/generator.ts:48-67`, `:132`; types `lib/jmap/sieve-types.ts:16-30`; changelog 1.7.3 "Extended filter rules — attachment field and multi-value conditions").
- What RN does: `src/lib/sieve/types.ts:21-26` has no `attachment` field. A single-value attachment rule from WEB passes RN's metadata validation (`src/lib/sieve/parser.ts:40-44` only requires strings) and is listed as an editable Bulwark rule. RN's generator has no attachment branch: `src/lib/sieve/generator.ts:36-62` falls through to `HEADER_MAP['attachment']` → `undefined`, emitting `header :contains "undefined" ""` and never adding `mime` to requires. Every RN save regenerates the whole script (`src/stores/filter-store.ts:102-118`: toggle, reorder, add, delete), so one toggle on the phone silently breaks the user's attachment rules server-side. WEB then re-reads that broken condition as a generic header rule.
- Fix hint: port the WEB generator/parser/types diff (see next item) - `git diff` between `lib/sieve/generator.ts` and `src/lib/sieve/generator.ts` is ~60 lines. Until then, at minimum make `generateCondition` throw / keep `rawBlock` for unknown fields instead of emitting `"undefined"`.
- [x] **Multi-value conditions (string[] values) unsupported → WEB scripts become opaque on RN** — fixed in 1c6aa68 — `P1` — `missing`
- What WEB does: `FilterCondition.value: string | string[]` (`lib/jmap/sieve-types.ts:40-49`); generator emits Sieve string-lists via `toValueList`/`formatStringArg` (`lib/sieve/generator.ts:15-32`, `57-90`); parser accepts list literals with `parseValueTail`/`classifyMatches` (`lib/sieve/parser.ts:344-400`, `455-484`) and validates array values (`parser.ts:37-44`); modal splits comma input on blur/save (`components/filters/filter-rule-modal.tsx:59-78`, `131-147`, `353-381`); summaries join with the locale's "or" (`components/settings/filter-settings.tsx:46-51`, `114-125`).
- What RN does: `src/lib/sieve/types.ts:38` `value: string`; `src/lib/sieve/parser.ts:40-44` rejects array values so `parseScript` returns `OPAQUE` (`parser.ts:681`). Result: after a WEB user types "a, b" in one condition, RN shows the "edited outside the visual builder" banner with only raw editing and "Reset to visual builder" (which regenerates an empty script → all rules lost, `src/stores/filter-store.ts:188` + `saveFilters`). If a list value somehow reached the RN modal, `cond.value.trim()` at `src/components/filters/FilterRuleModal.tsx:159` would throw and `<Input value={condition.value}>` (`:271`) would receive an array.
- Fix hint: apply the WEB diff to `types.ts`, `parser.ts` (`isValidCondition`, `parseValueTail`, `classifyMatches`, body regex), `generator.ts` (`toValueList`, `formatStringArg`, size scalar), then add comma-split/join helpers to `FilterRuleModal`, array-aware `summarizeRule`, and locale keys `value_placeholder_multi`, `attachment_type_placeholder`, `condition_fields.attachment`, `comparators.has_any/has_type` (present in WEB `locales/en/common.json`, absent in RN `locales/en/common.json` settings.filters).
- [x] **FilterRuleModal never re-initialises: editing rule B shows/saves rule A's fields** — fixed in 1c6aa68 — `P1` — `rn-only-bug`
- What WEB does: `FilterRuleModal` is conditionally mounted (`components/settings/filter-settings.tsx:697-707`), so `useState(rule?.name)` etc. run fresh per open.
- What RN does: `src/components/settings/FilterSettings.tsx:359-365` always renders `<FilterRuleModal visible={showRuleModal} rule={editingRule} .../>`; the modal seeds `name/matchType/conditions/actions/stopProcessing` only in `useState` initialisers (`src/components/filters/FilterRuleModal.tsx:74-82`) and has no reset effect. Opening "Add Rule" after editing a rule shows the previous rule's content; opening rule B after rule A shows A's fields but saves them under B's `id` (`:170`), overwriting B. Compare `SieveEditorSheet.tsx:33-39`, which does re-seed on `visible`.
- Fix hint: either mount the modal conditionally (`{showRuleModal && <FilterRuleModal .../>}`) or add a `useEffect([visible, rule])` that resets all local state like `SieveEditorSheet` does.
- [x] **Vacation-active banner is not actionable** — fixed in 3ffcef2 — `P3` — `partial`
- What WEB does: banner is a button that switches the settings tab to Vacation (`components/settings/filter-settings.tsx:483-507`, `vacation_configure` label).
- What RN does: plain `View` with title/description only (`src/components/settings/FilterSettings.tsx:231-241`); no "Configure →" affordance.
- Fix hint: accept an `onOpenVacation` prop from `SettingsScreen` and wrap the banner in a `Pressable`.
- [x] **Expanded ("IF / THEN") visual rule summary missing** — fixed in 3ffcef2 — `P3` — `partial`
- What WEB does: `VisualRuleSummary` renders condition/action chips with IF/THEN labels and the match-type hint when `expandedFilterView` is on (`components/settings/filter-settings.tsx:89-156`, `612-616`); changelog 1.4.6 "Add expanded visual view for filter rules".
- What RN does: the `filtersExpandedView` toggle exists (`src/components/settings/FilterSettings.tsx:350-355`) but only lifts the `numberOfLines` clamp on the same one-line summary (`:268`, `:292`); `if`/`then`/`match_all_conditions` locale keys are unused.
- Fix hint: add a `VisualRuleSummary` component rendering rows of chips (all conditions, all actions) when `expandedView` is true.
- [ ] **Per-account (shared/group) filters not supported** — `P3` — `missing` (depends on whether RN gets managed shared-account settings at all) — deferred: RN has no managed/shared-account settings yet
- What WEB does: `filter-store` tracks `availableAccounts`/`selectedAccountId` and every Sieve call takes an `accountId` (`stores/filter-store.ts:62-134`); `FilterSettings` scopes to `managedAccountId` and fetches that account's mailboxes for the move target list (`components/settings/filter-settings.tsx:188-232`, `248-255`); client `getSieveAccounts()` (`lib/jmap/client.ts:4465-4469`).
- What RN does: `src/api/sieve.ts:20-23` always uses the primary Sieve account; store has no account selection (`src/stores/filter-store.ts:32`). No `managedAccount` concept exists in RN (`grep -ri managedAccount src` → none).
- Fix hint: only if the Accounts area adds shared-account management: thread an optional `accountId` through `api/sieve.ts` and the store's `fetchFilters/saveFilters`.
- [x] **Sieve test files have mojibake in test names** — fixed in 1c6aa68 — `P3` — `rn-only-bug`
- What WEB does: `lib/sieve/__tests__/*.test.ts` use "→" in `it(...)` names.
- What RN does: `src/lib/sieve/__tests__/{parser,generator,external-rules}.test.ts` start with a UTF-8 BOM and contain "→" (double-encoded arrow) at e.g. `external-rules.test.ts:257`, `:343`, `generator.test.ts:369`, `parser.test.ts:104`. Cosmetic, but it shows the files were copied through a wrong encoding; re-copy from WEB when porting the 1.7.3 changes and add tests for list values / `:mime`.
- Fix hint: rewrite the three files as UTF-8 without BOM from the WEB copies.
- [ ] **Raw Sieve editor lacks line-number gutter / Tab-inserts-spaces** — `P3` — `partial` (mostly N/A on mobile)
- What WEB does: gutter with line numbers, Tab inserts two spaces (`components/filters/sieve-editor-modal.tsx:61-72`, `101-128`).
- What RN does: shows "N lines" below the editor (`src/components/filters/SieveEditorSheet.tsx:107`). Acceptable on mobile; listed so nobody re-audits it.
### Vacation responder
- [x] **fromDate/toDate sent without timezone designator (not a JMAP UTCDate)** — fixed in b538a0e — `P2` — `rn-only-bug` — not verified live against stw-test; RFC 8621 UTCDate semantics implemented
- What WEB does: `datetime-local` → `new Date(local).toISOString()` (`"...T10:00:00.000Z"`) (`components/settings/vacation-settings.tsx:196`, `:207`) and displays via `utcToLocalDatetime` (`:16-20`).
- What RN does: `toJmapDate` produces `"YYYY-MM-DDTHH:MM:SS"` with no `Z`/offset (`src/components/settings/VacationSettings.tsx:10-19`) and `fromJmapDate` just slices the server's UTC string, showing UTC as if local (`:21-24`). RFC 8621 `VacationResponse.fromDate` is a `UTCDate`; Stalwart will either reject the set (`invalidProperties`) or interpret the wall time as UTC, shifting the schedule by the user's offset. Not verified live - verify against stw-test (`reference_stalwart_stw_test_container.md`) before fixing.
- Fix hint: parse the typed local time with `new Date(y, m-1, d, hh, mm).toISOString()` and format for display with the device timezone; better, use a native date-time picker.
- [x] **HTML vacation body missing** — fixed in b538a0e — `P2` — `missing`
- What WEB does: "Formatted message (HTML)" toggle + `RichTextEditor`, sanitised on save with a plain-text fallback derived from the HTML, HTML preview (`components/settings/vacation-settings.tsx:47-48`, `81-83`, `109-127`, `242-258`, `279-293`); store carries `htmlBody` (`stores/vacation-store.ts:10`); changelog 1.7.8 "Vacation: HTML body support".
- What RN does: store/API already carry `htmlBody` (`src/api/vacation.ts:11`, `src/stores/vacation-store.ts:15`) but the screen never reads or writes it (`src/components/settings/VacationSettings.tsx:63-71` saves only `isEnabled/fromDate/toDate/subject/textBody`). Saving from RN leaves any WEB-set `htmlBody` untouched (partial update), so no data loss - just no way to see/edit/clear it.
- Fix hint: reuse the composer's editor (RN rich editor) behind an "HTML" toggle; when off, send `htmlBody: null`; derive `textBody` from HTML when empty.
- [x] **No "unsaved changes" gating; Save always enabled** — fixed in b538a0e — `P3` — `partial`
- What WEB does: `hasChanges` compares local vs. store and disables Save when nothing changed or the end is before the start (`components/settings/vacation-settings.tsx:94-102`, `317`).
- What RN does: `canSave` only checks warnings (`src/components/settings/VacationSettings.tsx:61`).
- Fix hint: add a `hasChanges` memo mirroring WEB.
- [x] **"Start date in the past" warning missing** — fixed in b538a0e — `P3` — `partial`
- What WEB does: `warnings.start_in_past` (`components/settings/vacation-settings.tsx:75-79`).
- What RN does: only end-before-start, format, empty-body (`src/components/settings/VacationSettings.tsx:56-59`).
- Fix hint: add the check; RN `locales/en/common.json` already has `settings.vacation.warnings`.
- [x] **Vacation screen is hard-coded English despite locale keys existing** — fixed in b538a0e — `P3` — `rn-only-bug`
- What WEB does: all strings via `settings.vacation.*`.
- What RN does: `VacationSettings.tsx` never calls `useLocaleStore`; "Vacation Responder", "Date Range", "Saved", warnings etc. are literals (`:57-59`, `:72-74`, `:88`, `:101-158`). RN `locales/en/common.json` already contains `settings.vacation.{title,description,status,date_range,message,preview,save,saving,warnings}`.
- Fix hint: wire `t('settings.vacation.…')` like `FilterSettings.tsx` does.
- [ ] **Vacation fetch/save on a shared account** — `P3` — `missing` (same dependency as the filters item) — deferred: same dependency (no managed-account concept in RN)
- What WEB does: `fetchVacationResponse(client, managedAccountId)` (`components/settings/vacation-settings.tsx:52-56`, `121-128`).
- What RN does: always `jmapClient.accountId` (`src/api/vacation.ts:27`, `:43`).
### Files (JMAP FileNode)
- [x] **Modification date never shown / sort-by-modified is a no-op (reads `updated`, server property is `modified`)** — fixed in a0ce925 — `P2` — `bugfix-parity` (changelog 1.8.1 "Files: Show the modification date instead of the creation date (#700)", commit 348e032d)
- What WEB does: requests and reads `modified` (`lib/jmap/client.ts:6478-6486` `FILE_NODE_PROPERTIES`; `lib/jmap/types.ts:891-895` explains that asking for the wrong name silently yields `undefined`; `stores/file-store.ts:177` `lastModified: node.modified || node.created`).
- What RN does: `src/api/files.ts:18-21` requests `'updated'`; `src/api/types.ts:548` declares `updated?: string`; `FilesScreen.tsx:222-225` sorts on `a.updated` and `:527-529` renders `item.updated` → always undefined, so the list shows no date and "Sort: Modified" does nothing. `FilesSettings` preview promises a Modified column.
- Fix hint: rename to `modified` in `FILE_NODE_PROPERTIES`, the `FileNode` type, `FilesScreen` sort/render; fall back to `created` like WEB.
- [x] **Percent-encoded names from WebDAV-created nodes shown raw** — fixed in a0ce925 — `P2` — `bugfix-parity` (changelog 1.9.0 "Decode percent-encoded FileNode names from WebDAV-created nodes (#869)", commit 5109aa05)
- What WEB does: `decodeFileNodeName` (`lib/jmap/filenode-name.ts:17-28`) applied at the client boundary in `getFileNodes`/`listAllFileNodes`/`listAllFileNodesAcrossAccounts` (`lib/jmap/client.ts:23-25`, `:6518`, `:6567`, `:6594`); conservative (needs a valid `%XX`, refuses decoded `/` or NUL).
- What RN does: `getAllFileNodes`/`getAllFileNodesAcrossAccounts` (`src/api/files.ts:61-126`) return `node.name` verbatim; "Spares%20Catalog" shows as such.
- Fix hint: port `filenode-name.ts` to `src/lib/` and map nodes through it in both list functions (keep the raw name only if a rename must round-trip the original - WEB doesn't).
- [x] **Files drive not reset on account switch** — fixed in c0e1d6b — `P2` — `bugfix-parity` (changelog 1.9.0 "Reset the account-scoped Files drive on every account switch", commit 5c06ae8b)
- What WEB does: `initedAccountRef` tracks the account; on change it `clearClient()`s and re-bootstraps (`components/files/files-app.tsx:110-114`, `160-179`).
- What RN does: `FilesScreen` keeps `allNodes`/`path` in component state and loads only on mount (`src/screens/FilesScreen.tsx:113-114`, `171-173`); `switchAccount` resets contacts/calendar stores but nothing files-related (`src/stores/auth-store.ts:360-380`). Switching accounts while the Files tab is mounted keeps showing the previous account's tree (and any create/upload goes to the new account under a stale `parentId`) until pull-to-refresh.
- Fix hint: subscribe to `useAuthStore(s => s.activeAccountId)` in `FilesScreen` and reset `path`/`selection` + call `loadFiles()` on change, or move the node cache into a store that `switchAccount` resets.
- [x] **Files capability gated on session capabilities, not the account's** — fixed in a0ce925 — `P3` — `bugfix-parity` (changelog 1.7.5 "Hide Files when the account lacks the filenode capability (#563)")
- What WEB does: `supportsFiles(accountId)` checks `account.accountCapabilities` (or non-personal) and `probeFileNodeSupport` refuses to probe when the server advertises filenode but the account doesn't (`lib/jmap/client.ts:6418-6458`).
- What RN does: `useHasFiles()` checks `session.capabilities` (`src/lib/capabilities.ts:33-35`); `App.tsx:184-193` disables the tab from that. An account whose `jmap-file-node-*` permissions were revoked still gets an enabled Files tab that errors on load.
- Fix hint: check `session.accounts[filesAccountId].accountCapabilities[CAPABILITIES.FILES]` (or `!isPersonal`) in `useHasFiles`.
- [x] **No upload size limit from server config** — fixed in c0e1d6b — `P3` — `bugfix-parity` (changelog 1.4.10 "Files: Use dynamic server-configured maximum upload sizes")
- What WEB does: filters oversized files against `client.getMaxSizeUpload()` and toasts `file_too_large` (`components/files/files-app.tsx:311-329`; `lib/jmap/client.ts:4179-4182`).
- What RN does: `startUpload` passes the picked asset straight to `uploadFileNode` (`src/screens/FilesScreen.tsx:345-381`); `grep -ri maxSizeUpload src` → nothing. The server returns an opaque 4xx instead.
- Fix hint: read `session.capabilities['urn:ietf:params:jmap:core'].maxSizeUpload` and compare with `asset.size` before uploading.
- [x] **Upload buffers whole file in memory, no progress, no cancel** — fixed in a0ce925 — `P2` — `partial` (changelog 1.4.12 "Stream WebDAV PUT uploads (#162)", 1.7.2 "Report real upload progress (#333)")
- What WEB does: `uploadBlob(file, { signal, onProgress })` with XHR progress and an `AbortController`; progress bar with percent and Cancel (`stores/file-store.ts:591-658`; `components/files/file-browser.tsx:1178-1212`).
- What RN does: `uploadBlob` reads the entire file with `new File(uri).bytes()` and posts an `ArrayBuffer` (`src/api/blob.ts:22-31`); `FilesScreen` shows only a spinner in the header button (`:460-464`). Large videos/PDFs from the document picker can exhaust memory; the user cannot cancel.
- Fix hint: use `expo-file-system` `uploadAsync`/`FileSystem.createUploadTask` (supports progress callbacks and cancellation, streams from disk) for the JMAP upload URL, then `FileNode/set`.
- [x] **Single-file upload only; no duplicate-name handling** — fixed in c0e1d6b — `P3` — `partial`
- What WEB does: multi-select input (`file-browser.tsx:1135-1141`), per-file progress `current/totalFiles`, `getUniqueName` appends " (1)" when a name already exists (`stores/file-store.ts:211-219`, `622-628`).
- What RN does: `getDocumentAsync({ multiple: false })` (`src/screens/FilesScreen.tsx:357-360`); uploading the same name twice creates two nodes with the same name.
- Fix hint: `multiple: true` + sequential upload loop; reuse WEB's `getUniqueName` against `visibleFiles`.
- [ ] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — deferred: the OS viewer/share sheet stays the mobile answer for now; no inline viewer ported
- What WEB does: `ImagePreviewModal` and `FilePreviewModal` (text, markdown, PDF incl. mobile pdf.js viewer, audio, video, EML) chosen via `getFilePreviewKind` (`lib/file-preview.ts:29-68`; `components/files/file-preview-modal.tsx`, `image-preview-modal.tsx`, `pdf-mobile-viewer.tsx`; `files-app.tsx:487-495`, `690-708`).
- What RN does: tapping a file calls `shareAttachment` which downloads to cache and hands off to the OS share/viewer sheet (`src/screens/FilesScreen.tsx:248-258`; `src/lib/email-export.ts` "preview" variant). Works but there is no inline viewer and no "recent files" tracking.
- Fix hint: reuse whatever attachment viewer the mail area has (image modal / WebView for text+PDF); N/A if the team decides the OS viewer is the mobile answer - then close this item.
- [x] **Move (to folder / to parent), cut/copy/paste, duplicate, new text file, undo missing** — fixed in c0e1d6b — `P2` — `missing` — Move to… (folder picker) and Duplicate done; deferred: cut/copy/paste, new text file, undo (lower priority on mobile)
- What WEB does: `moveToFolder`, `moveToParent`, `cutResources`/`copyResources`/`pasteResources` (copy via `copyFileNode` = new node reusing the blobId), `duplicateResource`, `createTextFile`, `undoLastAction` with toast Undo (`stores/file-store.ts:825-933`, `1006-1015`; `lib/jmap/client.ts:6787-6830` `copyFileNode`; context menu `file-browser.tsx:1674-1801`; drag-drop onto folders and ".." rows).
- What RN does: actions sheet offers Preview/Share, Save to device, Sharing & access, Rename, Delete only (`src/screens/FilesScreen.tsx:804-858`); `src/api/files.ts` has `updateFileNode(id, { parentId })` (`:149-160`) so move is one call away, but no copy helper.
- Fix hint: add "Move to…" (folder picker built from `allNodes` folders, then `updateFileNode(id, { parentId })`) and "Duplicate" (`FileNode/set create` with original `blobId/type/size`, mirroring WEB `copyFileNode`). Cut/copy/paste and undo are lower priority on mobile.
- [x] **Favorites, recent files, search/filter, details pane missing** — fixed in c0e1d6b — `P3` — `missing` — search field and a type/size/modified line in the actions sheet done; deferred: favorites/recent (optional)
- What WEB does: favorites and recent (pruned against server nodes on refresh, changelog 1.4.12 #146) persisted in localStorage (`stores/file-store.ts:243-248`, `518-527`, `988-1004`); in-list search (`file-browser.tsx:494-525`, `1106-1132`); details sidebar with type/size/modified/path (`:1929-1969`).
- What RN does: none of these (`FilesScreen.tsx` has no search state; settings has no favorites store).
- Fix hint: a header search field filtering `visibleFiles` is cheap; details can be a row in the actions sheet; favorites/recent optional.
- [x] **Multi-select batch download; batch delete only** — fixed in c0e1d6b — `P3` — `partial`
- What WEB does: multi-select toolbar offers Download (N) and Delete (N) (`file-browser.tsx:979-999`).
- What RN does: selection header offers Delete only (`src/screens/FilesScreen.tsx:405-427`).
- [x] **Sort direction/key only changeable in Settings; no in-screen column sort** — fixed in c0e1d6b — `P3` — `partial`
- What WEB does: clickable Name/Size/Modified headers toggle sort (`file-browser.tsx:814-828`, `1529-1546`).
- What RN does: sort comes from `filesDefaultSortKey/Dir` settings only (`FilesScreen.tsx:134-135`, `210-231`).
- Fix hint: a sort chip in the header cycling name/size/modified + direction; fix the `modified` property first.
- [x] **Settings exposed but ignored: folder layout "Sidebar" and "Show thumbnails"** — fixed in c0e1d6b — `P3` — `rn-only-bug` — thumbnails implemented (authenticated Image), folder-layout radio removed (sidebar is N/A on a phone)
- What WEB does: `folderLayout: 'sidebar'` renders `FolderTreeSidebar` and hides folders from the list (`file-browser.tsx:497-500`, `1223-1269`); `showThumbnails` renders image thumbnails (`:272-297`, `:1494-1496`).
- What RN does: `FilesSettings.tsx:161-171`, `219-224` offer both toggles and preview them, but `FilesScreen` never reads `filesFolderLayout` or `filesShowThumbnails` (only `showIcons/coloredIcons/showHiddenFiles/sort/view`, `:131-136`).
- Fix hint: either implement thumbnails (`<Image source={{ uri: getFileNodeDownloadUrl(node), headers: { Authorization } }}>` for image extensions) and drop the folder-layout radio (sidebar is N/A on a phone), or hide both settings.
- [ ] **Legacy flat-name migration not run on RN** — `P3` — `missing` (changelog 1.7.3 #379; WEB `stores/file-store.ts:286-479`) — deferred: WEB runs the one-time migration; not ported to mobile
- What WEB does: on first listing, reparents nodes named with `/`, `∕`, `⁄`, `/` separators and replaces blob-backed "dir marker" files with real folders, with a progress overlay (`files-app.tsx:245-263`, `710-734`).
- What RN does: reads only the real hierarchy (`src/api/files.ts:6-14` comment acknowledges WEB migrates). A user who never opens WEB sees legacy files as flat names at the root. Acceptable if WEB is always used at least once; otherwise port `migrateLegacyFlatNodes`.
- [ ] **Deep link `/files/<folder>` and `?preview=` not handled** — `P3` — `missing` (changelog 1.8.1 "Deep links for mail, calendar, contacts, files") — deferred: belongs to the navigation area (RN has no linking config yet)
- What WEB does: `parseFilesPath`/`buildFilesPath` walk the drive to the folder and open the preview (`components/files/files-app.tsx:190-278`; `lib/deep-links.ts:381`).
- What RN does: no React Navigation `linking` config anywhere (`grep -rn "linking" src App.tsx` → none); `Files: undefined` in `src/navigation/types.ts:35`.
- Fix hint: belongs to the navigation area; when linking is added, map `/files/*` to `Files` with a `path` param and walk `allNodes` by names.
- [x] **Hard-coded English throughout Files screen and ShareSheet** — fixed in c0e1d6b — `P3` — `rn-only-bug`
- What WEB does: everything via `files.*` locale keys (`locales/en/common.json` `files` has ~120 keys incl. `share`, `shared`, `shared_with_me`, `shared_by`).
- What RN does: `FilesScreen.tsx` ("New folder", "Delete this item?", "Shared by", "Upload unavailable"…, e.g. `:148`, `:310`, `:521`, `:621`, `:722-727`, `:825-849`) and `ShareSheet.tsx` (`:41-45`, `:160-166`, `:175`, `:219-242`) use literals, even though RN `locales/en/common.json` already has a `files` block (`:1513`) with most of these keys.
- Fix hint: wire `useLocaleStore(s => s.t)` with the existing keys; add `share/shared/shared_with_me/shared_by` from WEB.
- [ ] **Stability warning banner not shown** — `P3` — `partial` — deferred: product call, not shown on mobile
- What WEB does: persistent yellow "stability_warning" above the browser (`files-app.tsx:617-620`; changelog 1.4.10 "Update file feature disabled messages and add stability warnings").
- What RN does: nothing. Optional; product call.
- [ ] **Storage quota not surfaced in Files** — `P3` — `missing` (partly N/A) — N/A: quota belongs to the Account/Settings area, Files shows none (like WEB's mobile layout)
- What WEB does: passes `quota` from the email store into the navigation rail beside Files (`files-app.tsx:55`, `:569`); changelog 1.7.5 "Storage quota not shown with Stalwart (#577)".
- What RN does: `FilesScreen` shows no quota. Whether RN shows quota anywhere is outside this area; if it exists in Settings/Account, this is N/A.
- [x] **README still says "file storage - UI stubs only"** — fixed in c0e1d6b — `P3` — `rn-only-bug` (docs)
- `repos/react-native/README.md:30` lists filters, S/MIME, plugins, themes and file storage as stubs; filters, vacation and files are real implementations (`src/api/files.ts`, `src/screens/FilesScreen.tsx`, `src/components/files/ShareSheet.tsx`, `src/api/__tests__/files.test.ts`). Update the README when the items above land.
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Sieve parser/generator/types are a faithful port up to 1.7.2: `diff -w --strip-trailing-cr lib/sieve/parser.ts repos/react-native/src/lib/sieve/parser.ts` shows only the multi-value/attachment hunks (and `debug.warn` → `console.warn`). Included and identical: `@metadata` JSON round-trip, external-rule parsing with origin labels (Roundcube/Nextcloud/etc., changelog 1.4.14 #201), Nextcloud marker regions preserved verbatim, `# Rule:` dedupe of Bulwark blocks that failed to re-parse (1.7.0 "literal braces" fix, `parser.ts` `findBodyOpenBrace` + `filteredExternal`), vacation-only script detection, `externalRequires` merge, `INBOX` canonical path (1.7.1 #313: `FilterRuleModal.tsx:57`), all 10 action types, `computeRequires` (fileinto/copy/imap4flags/reject/body/vacation), `stop` folding into `stopProcessing`.
- Filter store semantics identical: skip server-managed `vacation` script (1.4.10), keep activation via `onSuccessActivateScript` on create/update (1.4.10), external/opaque rules read-only, bulwark rules kept contiguous before external ones, `addRule/updateRule/deleteRule/reorderRules/toggleRule`, rollback on failed save, opaque banner with "Open raw editor" / two-step "Reset to visual builder", `SieveScript/validate` via uploaded blob with error-method handling, `createSieveScript('filters')`.
- Filter rule editor: name/match type/conditions (from/to/cc/subject/header+name/size/body)/comparators/actions (move & copy folder picker from own non-shared mailboxes, forward, reject text, add_label from keywords store, mark read/star/discard/keep/stop)/stop-processing; empty-name/condition/action validation; comparator reconciliation on field change; default first mailbox for move/copy. Reorder via up/down chevrons instead of drag (fine on mobile). Delete uses a native confirm. Mailboxes are loaded at login (`src/stores/auth-store.ts:84`), so WEB fix #485 (load mailboxes when Filters opened directly) is not needed.
- Raw Sieve editor: warning banner, Validate with server errors, two-step confirm save, re-seeds on open, atomic `setOpaqueScript`.
- Vacation: `VacationResponse/get|set` on singleton with `core+mail+vacationresponse` using, default object when the list is empty, capability gating (`isVacationSupported`), enable toggle + status pill, subject, plain-text body, preview, end-before-start warning, empty-body warning, error surfacing.
- Files API: folder detection `blobId == null` (`src/api/files.ts:12-14`), `FileNode/get ids:null` (not `/query`, which drops folders), `shareWith/myRights` requested explicitly, `principals:owner` in `using`, real-hierarchy folder create without blob/type/size, cascade delete with `onDestroyRemoveChildren`, MIME-type >30 chars → `application/octet-stream` (1.4.12), cross-account "shared with me" aggregation with `accountId:nodeId` namespacing and owner-routed download URLs, shared subtrees browsable and writes hidden inside them, `Share2`/`Users` badges, ShareSheet with the same read/readWrite/manager presets as WEB `FILE_PRESETS`, custom-rights detection, principal search excluding self, revoke, refresh after change; unit tests in `src/api/__tests__/files.test.ts`.
- Files UI: list/grid toggle persisted to settings, folder breadcrumb, pull-to-refresh, hidden-file filter, colored/plain icons, long-press multi-select (own nodes only), rename/new-folder prompts rejecting `/`, delete confirmation naming folder cascade, path stack pruned when a folder disappears, Files tab disabled when the capability is missing.
## N/A on mobile
- Drag-and-drop upload / whole-folder upload (`uploadFolder`, `getDroppedFilesAndFolders`), drag-out of files, marquee selection, keyboard shortcuts (Ctrl+A/C/X/V, F2, Delete, Backspace), right-click context menus, resizable folder-tree sidebar, breadcrumb right-click dropdown, `?preview=` window title updates, `window.open` blob preview safety list (`isMimeTypeSafeForInlinePreview`).
- WebDAV proxy client (`lib/webdav/client.ts`, `stores/webdav-store.ts`) - WEB itself only uses it for calendar collections now; the Files app is JMAP-only, so RN correctly has no WebDAV code.
- Pro-shell cross-account Files picker / `accountFolders` / `__account_root__` breadcrumb sentinel, `filesEnabled` admin policy gate (RN has no policy store), plugin hooks `onSieveScriptGenerate`/`onFiltersSave`.
- Drag-to-reorder filter rules (RN uses chevrons), line-number gutter in the Sieve editor.
- "Attach from Files" in the composer and "Save attachment to Files" in the viewer: neither client implements these (WEB composer only takes local files, `components/email/email-composer.tsx:1391-1555`; no `createFileNode` call outside the Files store), so nothing to port.
+347
View File
@@ -0,0 +1,347 @@
# Settings & settings sync, push notifications, i18n, themes, updates, plugins, misc UI
All paths below are relative to WEB root `the webmail repo` unless prefixed `RN:` (= `repos/react-native/`). Line numbers are from the tree as of 2026-08-29.
## Summary
Settings parity is roughly "half the keys, a third of the behaviour": RN persists 77 keys (`RN: src/stores/settings-store.ts:102-230`) against WEB's ~110 (`stores/settings-store.ts:540-735`), but 16 of the RN keys are stored and never read anywhere outside the settings UI (stub toggles), several defaults silently diverge from WEB, and there is no cross-device sync at all (native issue #1) because WEB sync is a webmail-server feature (`/api/settings`, cookie-bound, AES-GCM with `SESSION_SECRET`) that a direct-to-JMAP native client cannot reach. Push is the biggest functional gap: RN subscribes to `Email`/`Mailbox` state changes (WEB only `EmailDelivery`), has no `emailPush` spam filter, ignores the message ids the relay forwards and instead guesses "newest unread in Inbox" (which produces a wrong notification when the newest unread is read elsewhere), never matches the relay payload to an account (scans for a field the relay does not send), has no device list / revoke / force-recreate, and is Android/FCM-only (no iOS, no UnifiedPush; native issues #44/#45/#48). i18n in RN is a flat-lookup shim: 15 of WEB's 27 locales, no interpolation, no plurals, no RTL, vendored catalogs six weeks stale, and 84 of 94 screen/component files never call `t()` at all (every settings pane except Language and Filters is hard-coded English). Themes/plugins/S-MIME/sidebar apps are visual stubs whose controls persist state nobody reads; the update path is a working Android sideloader with no severity concept and a dismissable banner (WEB's security/deprecated notices are non-dismissable).
## Settings inventory
Legend: RN? = key exists in RN store; Honored = read by RN app code outside `stores/settings-store.ts` and `components/settings/*` (verified by grep, 2026-08-29). "Owner" names the agent that covers the behaviour when it is not this area.
| WEB key (default) | RN key (default) | RN? | Honored | Notes |
|---|---|---|---|---|
| fontSize (medium) | fontSize (medium) | yes | partial | Only `useTypography()` callers scale: EmailListScreen, SidebarDrawer, CalendarSidebarDrawer (`RN: src/theme/dynamic.ts:47`); 89 files import static `typography` from tokens |
| density (regular) | density (regular) | yes | yes | EmailListScreen via `useDensity()` |
| animationsEnabled (true) | animationsEnabled (true) | yes | partial | Only via `useAnimDuration` in 3 files; 18 files call `Animated.timing` with fixed durations (UndoSnackbar, SwipeableRow, every sheet) |
| messageListOrder / messageListOrderScope (#718) | mailSortAscending (false) | partial | yes | Single boolean; native issue #5. Owner: mail-list agent |
| dateFormat (smart) | dateFormat (smart) | yes | yes | `RN: src/lib/date-format.ts` |
| dateLocale (auto) | – | no | – | Regional numeric order (1.7.x "User-selectable regional date format") missing |
| timeFormat (24h) | timeFormat (24h) + calendarTimeFormat (24h) | yes | yes | RN has two independent time-format settings; WEB has one |
| firstDayOfWeek (1) | calendarFirstDayOfWeek (1) | yes | yes | RN type is `0 \| 1`, WEB also allows 6 (Saturday) |
| timeZone (auto, #755) | – | no | – | Missing |
| markAsReadDelay (0) | markAsReadDelay (0) | yes | yes | |
| deleteAction (trash) | deleteAction (trash) | yes | yes | email-store |
| permanentlyDeleteJunk (false) | same | yes | yes | |
| returnToListAfterAction (true) | – | no | – | Owner: mail viewer agent |
| showPreview (true) | showPreview (true) | yes | yes | |
| mailLayout (split) | mailLayout (split) | yes | **no** | ReadingSettings offers Split/Focus, nothing reads it. N/A on phone; remove the control or hide it |
| emailsPerPage (50) | emailsPerPage (25) | yes | yes | Default differs |
| externalContentPolicy (ask) | same | yes | yes | EmailBodyView |
| messageSpacing (auto), plainTextFont (sans) | – | no | – | Owner: viewer agent |
| mailAttachmentAction / attachmentPosition | same | yes | yes | |
| emailAlwaysLightMode (false) | same | yes | yes | EmailBodyView:471 |
| archiveMode (single) | same | yes | yes | |
| hoverActions* | – | no | – | N/A on touch |
| swipeRightAction (archive) / swipeLeftAction (delete) | swipeRightAction (read) / swipeLeftAction (archive) + swipeMode | yes | yes | Defaults differ (WEB right=archive,left=delete; RN right=read,left=archive). RN adds `pin`, `move`, `reveal` mode; WEB adds `spam`. Owner: mail-list agent |
| autoSaveDraftInterval, sendConfirmation, defaultReplyMode, rtlEditingSupport, subAddressDelimiter, signaturePosition, signatureSeparatorEnabled, requestReadReceiptDefault, readReceiptResponse, emptySubjectWarningEnabled | – | no | – | Owner: compose agent |
| autoSelectReplyIdentity (false) | same (**true**) | yes | yes | Default differs |
| plainTextMode (false) | same | yes | yes | |
| sendDelaySeconds (0) | same (0, any number) | yes | yes | RN does not validate to 0/10/30/60 like WEB (`stores/settings-store.ts:901`) |
| attachmentReminderEnabled / attachmentReminderKeywords (multilingual list, :670-697) | same (4 English words, :255) | yes | yes | RN default list is English-only |
| sessionTimeout (0) | – | no | – | Owner: auth agent |
| trustedSenders ([]) | same | yes | yes | RN `addTrustedSender` does not strip `Name <addr>` (WEB :1008-1016) |
| trustedSendersAddressBook (null → auto true) | same (**false**) | yes | partial | Read by EmailBodyView:470 but there is no toggle in ContentSendersSettings and no auto-enable, so it is permanently off |
| expandedFilterView | filtersExpandedView | yes | yes | Only read inside FilterSettings itself (fine) |
| showTimeInMonthView (false) | calendarShowTimeInMonth (**true**) | yes | **no** | Stored, never read. Owner: calendar agent |
| showWeekNumbers | calendarShowWeekNumbers | yes | yes | |
| calendarHoverPreview | same | yes | **no** | N/A on touch; hide the control |
| enableCalendarTasks / showTasksOnCalendar | same | yes | yes / **no** | showTasksOnCalendar stored, never read |
| showBirthdayCalendar (false) | same (**true**) | yes | yes | Default differs |
| birthdayCalendarColor | – | no | – | Owner: calendar agent |
| sharedCalendarColors | same | yes | yes | |
| groupContactsByLetter | same | yes | yes | |
| emailNotificationsEnabled (true) | same | yes | yes | App.tsx:336, push-background-task.ts:32 |
| emailNotificationSound (true) | same | yes | **no** | Stored, never read; Android channel sound is whatever the OS default is |
| notificationSoundChoice (default/cheerful/involved/swift/relax) | same (default/chime/ping/pop/none) | yes | **no** | Different option set, never read, preview button has no `onPress` (`RN: NotificationSettings.tsx:205-210`) |
| pushRelayUrl ('' = admin default) | (AsyncStorage `push:relayBaseUrl:v1`, free text) | separate | yes | See push findings |
| calendarNotificationsEnabled / calendarNotificationSound | same | yes | **no** | Stored, never read (calendar-alerts lib does not consult them). Owner: calendar agent for the alarm side |
| calendarInvitationParsingEnabled | same | yes | yes | CalendarInvitationBanner |
| protocolOpenMode | – | no | – | N/A (web protocol handlers) |
| toolbarPosition | – | no | – | N/A |
| showToolbarLabels | same | yes | yes | EmailThreadScreen |
| hideAccountSwitcher, showRailAccountList, proInterface | – | no | – | N/A |
| enableUnifiedMailbox, unifiedCrossAccount, enableCross*View, allMailFolderIds | – | no | – | RN has a UnifiedInbox screen without these switches. Owner: mail-list agent |
| includeGroupInUnified (true) | same (**false**) | yes | yes | Default differs |
| preferredIdentityIds (per account, #507) | – | no | – | Owner: identity agent |
| disableThreading | same | yes | yes | |
| senderFavicons | same | yes | yes | |
| showAvatarsInJunk, faviconUnreadBadge, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount | – | no | – | faviconUnreadBadge N/A; others Owner: mail-list/sidebar agent |
| folderIcons, emailKeywords, nestedTags | – (keywords-store) | separate | – | Owner: mail agent |
| hideInlineImageAttachments / attachmentImagePreviewsEnabled | yes / no | | | |
| sidebarApps, keepAppsLoaded | same | yes | **no** | Never rendered anywhere (see Plugins section) |
| onboardingCompleted, tourCompleted, showOnboardingOnNewDevices | – | no | – | N/A (no tour) |
| emailDownloadTemplate, attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics | emailExportTemplate, attachmentExportTemplate, exportSpaceReplacement, exportLowercase, exportStripDiacritics | yes | yes | `RN: src/lib/email-export.ts` |
| bundleDownloadTemplate, filenameCollapseSeparators, postExportAction | – | no | – | Owner: mail viewer/export agent |
| debugMode, debugCategories | (React local state only) | no | **no** | `RN: AboutDataSettings.tsx:61-63` — toggles live in `useState`, lost on unmount, nothing reads them |
| settingsSyncDisabled | (local state) | no | **no** | `RN: AboutDataSettings.tsx:65,246-248` — a "Settings sync" switch that does nothing |
| – | theme (system) | RN-only | yes | WEB keeps this in theme-store |
| – | activeThemeId (null) | RN-only | **no** | Never read |
| – | smimeDefaultEncrypt / smimeRememberUnlocked / smimeAutoImport | RN-only | **no** | Stubs |
| – | pluginEnabled ({}) | RN-only | **no** | Stub |
| – | files* (8 keys) | RN-only | 6 of 8 | filesFolderLayout, filesShowThumbnails never read. Owner: files agent |
| – | bottomQuickActions, swipeMode, offlineCache* | RN-only | yes | |
RN toggles that are stored but never read (fix: either wire them or remove the control): `mailLayout`, `calendarShowTimeInMonth`, `calendarHoverPreview`, `showTasksOnCalendar`, `emailNotificationSound`, `notificationSoundChoice`, `calendarNotificationsEnabled`, `calendarNotificationSound`, `sidebarApps`, `keepAppsLoaded`, `activeThemeId`, `smimeDefaultEncrypt`, `smimeRememberUnlocked`, `smimeAutoImport`, `pluginEnabled`, `filesFolderLayout`, `filesShowThumbnails`.
## Findings
### Settings store and settings sync
- [ ] **No cross-device settings sync; RN cannot join the WEB sync store as-is (native #1)** — `P2` — `missing` — deferred (needs a server-side store); key-mapping table shipped as SETTINGS_KEY_MAP + DEVICE_LOCAL_KEYS in src/stores/settings-store.ts (8deff66) and used by export/import; the dead 'Settings sync' switch is gone
- What WEB does: every settings change (plus theme, locale, templates #825) is debounced 2 s and POSTed to the webmail server's `/api/settings` (`stores/settings-store.ts:1340-1359`, `:72-96`). The server verifies the caller against its own session cookies for any account slot (`app/api/settings/route.ts:71-94`), strips admin-locked keys (`:142-167`) and writes `sha256(username:serverUrl).enc` under `SETTINGS_DATA_DIR`, AES-256-GCM with key `sha256(SESSION_SECRET)` (`lib/settings-sync.ts:12-32,34-53`). On login it GETs the blob and imports it with per-account map merging (`stores/settings-store.ts:1125-1165`, `:942-951`). `proInterface` is device-local (`:147`).
- What RN does: persists a flat JSON under AsyncStorage key `webmail:settings:v1` (`RN: src/stores/settings-store.ts:91,393-397`) and nothing else. The AboutData "Settings sync" toggle is `useState` (`RN: AboutDataSettings.tsx:65,246-248`).
- Fix hint: RN talks to Stalwart directly and has no webmail session cookie, so it cannot call `/api/settings` (403 identity mismatch, and the deployment's webmail URL is unknown to it). Two realistic designs: (a) store the same JSON blob on the JMAP server as a Blob (`Blob/upload` + a well-known keyword/mailbox message, or a FileNode in the user's Files root such as `/.bulwark/settings.json`) and have WEB read/write the same object so both clients converge — needs a shared key-name mapping (WEB names above) and a `updatedAt` for last-writer-wins; or (b) let the login flow optionally capture the webmail origin (the QR sign-in already comes from webmail) and proxy sync through it with a bearer token. Either way, ship a key-mapping table first (RN `mailSortAscending` ↔ WEB `messageListOrder`, `calendarFirstDayOfWeek` ↔ `firstDayOfWeek`, `emailExportTemplate` ↔ `emailDownloadTemplate`, etc.) and exclude device-local keys (`swipeMode`, `bottomQuickActions`, `offlineCache*`, `theme`?).
- [x] **Default values silently diverge from WEB** — fixed in 8deff66 — `P3` — `partial`
- What WEB does: `emailsPerPage` 50, `autoSelectReplyIdentity` false, `showBirthdayCalendar` false, `showTimeInMonthView` false, `includeGroupInUnified` true, `trustedSendersAddressBook` null→auto-true, swipe right=archive/left=delete, multilingual attachment-reminder keyword list (`stores/settings-store.ts:540-735`).
- What RN does: 25, true, true, true, false, false, right=read/left=archive, 4 English keywords (`RN: src/stores/settings-store.ts:232-325`).
- Fix hint: align defaults where the behaviour is the same on both clients (attachment keywords, includeGroupInUnified, autoSelectReplyIdentity, showBirthdayCalendar) before any sync exists, otherwise the first sync flips them for existing users.
- [ ] **`trustedSendersAddressBook` can never be turned on in RN** — `P3` — `partial`
- What WEB does: `null` resolves to `true` on first connect when the server has contacts; the Content & Senders tab has a toggle (`components/settings/content-senders-settings.tsx`, `updateSetting('trustedSendersAddressBook'`).
- What RN does: default `false` (`RN: src/stores/settings-store.ts:239`), read by `EmailBodyView.tsx:470,489` but `ContentSendersSettings.tsx` has no control for it.
- Fix hint: add the toggle; treat undefined as auto-on when `useHasContacts()`.
- [x] **`addTrustedSender` does not normalise `Name <addr>`** — fixed in 8deff66 — `P3` — `bug`
- What WEB does: strips display-name angle form before storing/comparing (`stores/settings-store.ts:1008-1035`).
- What RN does: lowercases and trims only (`RN: src/stores/settings-store.ts:471-489`); callers that pass a formatted address store a value that will never match.
- Fix hint: port the angle-bracket regex into `add/remove/isSenderTrusted`.
- [x] **`sendDelaySeconds` and other enum keys not sanitised on hydrate** — fixed in 8deff66 — `P3` — `bug`
- What WEB does: rejects/resets invalid `sendDelaySeconds`, `messageListOrder`, `messageListOrderScope`, `subAddressDelimiter` on import and in `migrate` (`stores/settings-store.ts:898-930,1207-1209`).
- What RN does: `mergeWithDefaults` only checks `typeof` (`RN: src/stores/settings-store.ts:399-415`); a corrupt `swipeLeftAction: "foo"` or `density: "x"` flows into the UI.
- Fix hint: add per-key validators (allowed-value sets) to `mergeWithDefaults`; `normalizeBottomQuickActions` is the pattern.
- [x] **Reset-to-defaults only resets three keys; no export/import** — fixed in 8deff66 — `P3` — `partial`
- What WEB does: `resetToDefaults` restores every key; About & Data offers Export/Import JSON (feature-gated) and "Refresh cached data" (`components/settings/about-data-settings.tsx:77-129,171-220`, `lib/clear-cached-data.ts`).
- What RN does: `handleReset` resets `externalContentPolicy`, `senderFavicons` and trusted senders only (`RN: AboutDataSettings.tsx:104-110`); no export/import; "Clear cache" only clears the offline body cache.
- Fix hint: add `resetToDefaults` to the store (`set(DEFAULT_PERSISTED)` + persist), an export via `expo-sharing` and import via `expo-document-picker` using WEB's export shape so files round-trip between clients; add a "refresh cached data" that clears `email-snapshot`/contacts/calendar caches without logging out.
- [x] **Debug mode / categories are inert local state; no debug/logger abstraction** — fixed in 8deff66 — `P3` — `missing` — src/lib/debug.ts; dead Debug tab removed in 3d0d440
- What WEB does: `debugMode`/`debugCategories` persisted and honoured by `lib/debug.ts:8-13`; `lib/error-reporting.ts` funnels error-boundary reports; `lib/logger.ts` server side. Debug tab gated by admin (`settings-app.tsx:766`).
- What RN does: `AboutDataSettings.tsx:61-66,226-244` keeps toggles in `useState` with categories (`sync`, `render`) that exist nowhere; the "Debug" tab is `implemented: false` (`SettingsScreen.tsx:111`); code uses raw `console.warn`.
- Fix hint: persist `debugMode`/`debugCategories`, add `src/lib/debug.ts` mirroring WEB's category API, route the `[push]`, `[settings-store]`, `[updates-store]` warnings through it, and drop the dead Debug tab or implement a log viewer.
- [x] **Settings panes are hard-coded English (no i18n)** — fixed in 22697fd — `P2` — `partial` — all panes I own use t(); RN-only keys harvested into locales/rn/en.json (npm run i18n:harvest); Calendar/Contacts/ContentSenders/Filter/Vacation/Files/Folder panes belong to other agents
- What WEB does: every settings component uses `useTranslations` with keys in `locales/*/common.json`.
- What RN does: only `LanguageSettings.tsx` and `FilterSettings.tsx` call `t()`; the other 24 settings components plus `settings-section.tsx` have literal English strings (grep count 0 for `t(` in each). `SettingsScreen.tsx:184-187` translates group/tab labels only, and the "Experimental"/"Not implemented"/"Unavailable" badges (`:297,270-273`) are literal.
- Fix hint: the vendored catalog already contains `settings.*` keys for almost every label (WEB keys); wrap each label/description in `t('settings.<tab>.<key>', 'fallback')`.
- [x] **Settings tabs shown regardless of server capability / admin policy** — fixed in 3ffcef2 — `P3` — `partial` — Sieve/Vacation gating via useHasSieve/useHasVacation (filters agent's commit)
- What WEB does: hides Vacation/Filters when Sieve is absent, Templates/Keywords/Sidebar apps/Plugins/Themes/Debug behind admin feature flags, Security behind Stalwart features (`components/settings/settings-app.tsx:724-766`); settings can be `locked`/hidden per policy (`appearance-settings.tsx:89-119`).
- What RN does: hides only calendar/contacts/files by capability and updates by platform (`RN: SettingsScreen.tsx:142-179`); Vacation and Filters render even without Sieve; `SettingItem` has a `locked` prop (`settings-section.tsx:68-86`) that nothing sets. Admin policy is N/A (no webmail server), but Sieve gating is not.
- Fix hint: gate `vacation`/`filters` on `CAPABILITIES.SIEVE` in the session like WEB's `supportsSieve`/`supportsVacation`.
### Push notifications
- [x] **RN subscribes to `Email` and `Mailbox` state changes, not just `EmailDelivery`** — fixed in 00faceb — `P2` — `bugfix-parity`
- What WEB does: `PUSH_TYPES = ['EmailDelivery']` because `Email` fires on every mutation and produced spurious notifications (`lib/web-push.ts:39-44`; changelog 1.5.x "Scope new-mail notifications to genuine inbox deliveries").
- What RN does: `PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox']` (`RN: src/lib/push-notifications.ts:104`). Every read/flag/move/draft on any client wakes the device through FCM and runs the headless task.
- Fix hint: change to `['EmailDelivery']` and bump the subscription (the refresh path only patches `expires`, `:411-429`; add a `types` mismatch check like WEB `:369-383` so existing subscriptions get corrected).
- [x] **"Newest unread in Inbox" heuristic notifies the wrong message** — fixed in 00faceb — `P2` — `rn-only-bug`
- What WEB does: the service worker uses the `emailIds` the relay forwards (EmailPush) or, on StateChange, asks `/api/push/preview?accountId=&emailId=` (`app/api/push/preview/route.ts:88-119`).
- What RN does: `processAccountForPush` queries `notKeyword:$seen` in Inbox, limit 1, and notifies it unless it equals `lastNotified` (`RN: src/lib/push-background-task.ts:151-164`). Because RN also subscribes to `Email` changes, reading the newest unread message on another device triggers a push, the query now returns the *next older* unread message, which was never `lastNotified`, so the user gets a notification for old mail. Two messages arriving in one push yield one notification; mail Sieve-filed outside Inbox never notifies; the relay's `emailIds` (`repos/relay/src/payload.ts`, `fcm.ts` data.emailIds) are ignored.
- Fix hint: parse `data.emailIds` (JSON string) and `data.kind`; when present, `Email/get` those ids directly and post one notification per id (or a grouped one); fall back to the Inbox query only for legacy `jmap-state-change` payloads, and there compare against a stored set of seen ids rather than a single `lastNotified`.
- [x] **Per-account matching of the FCM payload never matches** — fixed in 00faceb — `P2` — `rn-only-bug` — local id → JMAP id map in push:jmapAccountIds:v1 (+AccountEntry.jmapAccountId), accountLabel fallback
- What WEB does: SW reads `accountId` from the payload's `changed` map (`app/api/push/preview/route.ts:90-99`).
- What RN does: `identifyAccountFromFcmData` scans payload values for a locally stored `deviceClientId` (`RN: src/lib/push-background-task.ts:67-87`), but the relay sends `kind, accountLabel, accountId, emailIds, changed` (`repos/relay/src/fcm.ts` message.data) - never the subscription id. Result: every push processes every logged-in account serially inside a 30 s headless budget (`BulwarkPushTaskService.kt` timeout), loading each account's JMAP session.
- Fix hint: the relay's `accountId` is the JMAP primary account id; persist each account's JMAP account id in `AccountEntry` (`RN: src/stores/account-store.ts:7-19` has none) or in the push registry (`push:accountIds:v1` → map local id → jmap id) at setup time, and match on that. `accountLabel` (= username) is a weaker second key.
- [x] **No `emailPush` delivery filter (spam still wakes the device)** — fixed in 00faceb — `P2` — `bugfix-parity`
- What WEB does: when the session advertises `urn:ietf:params:jmap:emailpush` (Stalwart ≥ 0.16.16) the subscription carries a per-account filter `notKeyword:$junk AND inMailboxOtherThan:[junk ids]` (`lib/web-push.ts:53-108,491-497`, commit 63fa2d2d) and re-syncs it in the background on every page load (`:668-688`, `components/push-notification-prompt.tsx:119-126`, 1.9.2).
- What RN does: `createPushSubscription` has no `emailPush` parameter (`RN: src/api/push.ts:23-57`); the Inbox-only query hides junk from the notification but the wake-up and JMAP round-trip still happen for every spam delivery.
- Fix hint: port `buildEmailPushConfig` + `serverSupportsEmailPush` (session capabilities are in `jmapClient.currentSession.capabilities`), add `emailPush` to `createPushSubscription`/`updatePushSubscription`, and extend `refreshSubscriptionExpires` to patch `types`/`emailPush` when they differ.
- [x] **No device list, per-device revoke, or force-recreate (#841)** — fixed in f6f26f7 — `P2` — `missing`
- What WEB does: Notifications tab lists every `PushSubscription` on the account with relay liveness and "this device" marker, lets the user revoke any of them, and "Re-register" destroys and recreates the subscription so revoked shared-mailbox access stops fanning out (`lib/web-push.ts:565-639`, `:143,457-461`; `components/settings/notification-settings.tsx:169-193,251-303`; changelog 1.8.x).
- What RN does: "Re-register" calls `setupPushNotifications` which reuses the stored subscription and only refreshes `expires` (`RN: src/lib/push-notifications.ts:345-352`); no list/revoke UI.
- Fix hint: add `forceRecreate` to `PushSetupParams`, and a `listPushDevices`/`revokePushDevice` pair (JMAP `PushSubscription/get` + relay `/api/push/active/:id` + `DELETE /api/push/register/:id`) rendered under the relay card.
- [ ] **Relay URL is a free-text field instead of an admin-curated list; `DEFAULT_RELAY_BASE_URL` not overridable** — `P3` — `partial` — https now required (isValidRelayUrl, 00faceb); deferred: per-account relay storage / discovery
- What WEB does: user picks from `resolvePushRelayOptions(policy)` (default + admin list + legacy), never types a URL; admin can lock it (`lib/push-relays.ts:39-80`, `notification-settings.tsx:226-241`).
- What RN does: `TextInput` with any `https?://` accepted (`RN: NotificationSettings.tsx:174-184`), stored device-wide in AsyncStorage (`push-notifications.ts:160-175`) so two accounts on different servers share one relay.
- Fix hint: keep free text (there is no policy source in RN) but store the relay per account alongside `push:accountIds:v1`, and let `.well-known`/login-time discovery optionally seed it. Not blocking.
- [x] **Re-register error path is opaque (native #45)** — fixed in 00faceb — `P2` — `bug` — PushSetupError{phase}, relay body in message, guarded getToken, per-account teardown keeps the FCM token; no device repro yet
- What WEB does: surfaces `WebPushUnsupportedError` separately and reports precise relay/JMAP failures; reaps only relay-confirmed-dead leftovers (`lib/web-push.ts:466-489`).
- What RN does: `setupPushNotificationsInner` calls `native.getToken()` directly (not the guarded `getFcmToken`) so a Firebase rejection (`fcm_token_failed`, typical on de-Googled devices or right after `deleteToken()` during Disable) bubbles up raw; `registerWithRelay` throws `Relay register failed: <status>` without the body (`RN: src/lib/push-notifications.ts:312-313,215-217`); `pollVerificationCode` times out after 75 s with the same message for every cause (`:268`). The UI shows `error.message` verbatim (`NotificationSettings.tsx:95-99`). Disable then immediately Enable also races: `teardownPushNotificationsForAccount` deletes the FCM token when it was the last account (`:477-481`) and the next `getToken()` may be rejected until Firebase re-registers.
- Fix hint: read the relay response body into the error, distinguish "no Google Play services" (map to the UnifiedPush finding), skip `deleteToken()` unless the user disables push for all accounts, and log each phase (`token`, `relay`, `jmap create`, `verify`) so the settings pane can show which step failed. Needs a repro on the reporter's device to confirm the exact failure.
- [ ] **iOS push missing entirely** — `P2` — `missing` — deferred: needs an APNs transport in the relay + iOS token module; the settings pane now states Android-only
- What WEB does: Web Push works on iOS 16.4+ once installed to the home screen (`lib/web-push.ts:157-164,394-398`).
- What RN does: `getNative()` returns `null` off Android and setup throws "Push notifications require Android" (`RN: src/lib/push-notifications.ts:125-128,303-304`); the relay has only FCM and Web Push transports (`repos/relay/README.md` endpoints).
- Fix hint: needs an APNs transport in the relay (`POST /api/push/register/apns` storing a device token, HTTP/2 to `api.push.apple.com` with a `.p8` key) plus an iOS native module (or `expo-notifications`) for the token and a Notification Service Extension to fetch sender/subject over JMAP. Content-blind payload stays the same.
- [ ] **No non-FCM transport (UnifiedPush, native #44/#48)** — `P3` — `missing` — deferred: relay must accept key-less web registrations + a UnifiedPush connector module
- What WEB does: N/A (browser push).
- What RN does: FCM only (`BulwarkMessagingService.kt`, `push-notifications.ts:120-128`).
- Fix hint: UnifiedPush distributors expose an RFC 8030 endpoint, which is exactly what the relay's `/api/push/register/web` already accepts (`repos/relay/src/server.ts` handleRegisterWeb); VAPID/`keys` are optional in UnifiedPush so the relay must accept a record without `p256dh`/`auth` and send unencrypted (or only the id). Client side: `unifiedpush-react-native`/`org.unifiedpush.android.connector` receiving the message and calling the same headless task.
- [x] **No "+N more messages" grouping** — fixed in 00faceb — `P3` — `missing`
- What WEB does: one notification per account with the newest message as headline and a "+N more" line, tag `bulwark-mail:<accountId>`, click opens inbox when grouped (`public/sw.js:180-216`; changelog 1.8.x).
- What RN does: one notification per message id `mail:<emailId>` with no `setGroup`/summary (`RN: BulwarkFcmModule.kt postNotification`).
- Fix hint: set `setGroup("bulwark-mail:"+accountId)` on each notification and post a summary notification with `setGroupSummary(true)` and an `InboxStyle` listing; Android does the "+N" rendering.
- [x] **Foreground FCM message is only logged** — fixed in 00faceb — `P3` — `partial`
- What WEB does: SW shows the notification regardless; in-app state refresh comes from the separate SSE channel.
- What RN does: `addMessageListener` handler just `console.log`s (`RN: App.tsx:300-305`); since the Kotlin service skips the headless task in the foreground (`BulwarkMessagingService.kt:26-31`) no notification is posted and no store refresh happens if SSE happens to be down (SSE fallback is 5 s polling, so the gap is small).
- Fix hint: on `fcm:message`, call `useEmailStore.getState().handleStateChange(JSON.parse(payload.data.changed))` (relay sends it) and optionally post an in-app banner.
- [x] **No push onboarding prompt** — fixed in f6f26f7 — `P3` — `missing`
- What WEB does: after the PWA install prompt, a per-account dismissable "enable notifications" card appears 1 s after login unless already enabled (`components/push-notification-prompt.tsx:128-177`; changelog 1.8.0 "Background notification onboarding").
- What RN does: push only starts if `getStoredRelayBaseUrl()` is non-null (`RN: App.tsx:353-354`), i.e. the user must find Settings → Notifications and press Enable; nothing invites them.
- Fix hint: on first authenticated launch, prompt once (with "don't ask again" persisted per account) and call `setupPushNotifications({ relayBaseUrl: DEFAULT_RELAY_BASE_URL })`.
- [x] **Notification sound settings are dead** — fixed in f6f26f7 — `P3` — `partial` — controls removed, channel hint shown; store keys dropped in the settings-store pass
- What WEB does: `notificationSoundChoice` picks one of 5 sounds with preview; email/calendar sound toggles gate playback (`lib/notification-sound.ts`, `notification-settings.tsx:305-331`).
- What RN does: `NotificationSettings.tsx:203-250` renders a sound `Select` (values `chime/ping/pop/none` that exist nowhere), a preview button without `onPress`, and email/calendar sound toggles that no code reads; the Android channel `bulwark_mail` uses the default sound and cannot change it after creation (`BulwarkMessagingService.kt:62-78`).
- Fix hint: on Android sounds belong to channels: create one channel per sound choice (or open the system channel settings via `Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS`), and remove the toggles that cannot be honoured.
### i18n and locales
- [x] **12 of WEB's 27 locales missing in RN** — fixed in 990cd84 — `P2` — `missing`
- What WEB does: `ar ca cs da de en es fa fr he hu it ja ko lv mn nb nl pl pt ro ru sk tr uk zh zh-TW` (`i18n/routing.ts:15`).
- What RN does: `SUPPORTED_LOCALES` has 15 (`RN: src/i18n/index.ts:21-37`); `scripts/sync-locales.mjs` copies every WEB folder (`:32-42`) but `locales/` only contains the 15 and the index imports them statically. Missing: ar, ca, da, fa, he, hu, mn, nb, ro, sk, tr, zh-TW.
- Fix hint: run the sync script, add the imports/labels, and handle `zh-TW` (region subtag) in `detectDeviceLocale` which only compares `languageCode` (`:45-52`).
- [x] **Vendored catalogs are stale and drift from WEB** — fixed in 990cd84 — `P2` — `partial` — RN-only keys live in locales/rn/<lang>.json (merged at runtime); sync-locales.mjs --check reports drift
- What WEB does: `locales/en/common.json` has 2959 leaf keys (2026-08-29).
- What RN does: `RN: locales/en/common.json` last synced 2026-07-22 has 2336 keys; 640 WEB keys are absent; RN en carries 17 keys WEB lacks (hand-added `email_composer.*`) which the 14 other RN locales do not have (de/fr/ja each miss the same 16 keys), and 19 keys RN code calls (`email_composer.schedule_*`, `email_list.no_*_folder`) exist in no catalog, so the inline fallback always shows in every language.
- Fix hint: make `sync-locales.mjs` merge instead of overwrite (keep RN-only keys under a separate `locales/rn/<lang>.json` overlay), add a key-parity test like WEB's translation coverage test, and run it in CI.
- [x] **`translate()` has no interpolation, no plurals, no rich text** — fixed in 990cd84 — `P2` — `partial` — hand-rolled plurals replaced in OfflineBanner/AboutData/Layout/Identity/Templates (8deff66, 22697fd); rich text (tags) not needed by any RN string
- What WEB does: next-intl ICU messages (`{count, plural, ...}`, `{name}` args).
- What RN does: plain nested lookup returning the raw string (`RN: src/i18n/index.ts:54-71`); any WEB string with `{placeholder}` renders literally, and plurals are hand-rolled English (`OfflineBanner.tsx:22`, `AboutDataSettings.tsx:96,176,190`, `LayoutSettings.tsx:148-149`).
- Fix hint: add `t(key, fallback, params)` with `{name}` substitution and a minimal ICU plural (`intl-messageformat` is small and works on Hermes), then replace the hand-rolled plurals.
- [ ] **No RTL support (ar/he/fa)** — `P2` — `missing` — done in 990cd84: forceRTL/allowRTL on override + restart hint, isLayoutRTL() helper in src/i18n; deferred: SwipeableRow side swap (mail-list agent's file - call isLayoutRTL() there)
- What WEB does: `getLocaleDirection` sets `dir=rtl`, logical CSS, JS popovers flip, RTL-aware swipe (`i18n/direction.ts`; changelog 1.7.0-1.7.3 RTL entries).
- What RN does: none of the three RTL locales are shipped; no `I18nManager` usage anywhere; `android:supportsRtl="true"` is set (`RN: AndroidManifest.xml:16`) so the OS would mirror layouts when the device locale is RTL, but the app's own locale override cannot force it, and `SwipeableRow` maps left/right physically.
- Fix hint: when adding ar/he/fa, call `I18nManager.forceRTL(dir==='rtl')` + `allowRTL` on override change (requires reload), audit `paddingLeft/Right` → `paddingStart/End`, and swap swipe actions when `I18nManager.isRTL`.
- [ ] **Language list not localized and no "auto" date locale** — `P3` — `partial` — deferred: language labels are native names (webmail parity); timeZone landed as calendarTimeZone (calendar agent); dateLocale needs a locale override threaded through src/lib/date-format.ts call sites in other areas
- What WEB does: language picker sorted, flags, `dateLocale` (auto/iso/en-GB/en-US), `firstDayOfWeek`, `timeZone` on the Language tab (`components/settings/language-settings.tsx` `updateSetting('dateLocale'|'firstDayOfWeek'|'timeZone'`).
- What RN does: `LanguageSettings.tsx` offers language, `dateFormat`, `timeFormat` only; day-of-week lives under Calendar; no time zone.
- Fix hint: add `dateLocale` and `timeZone` keys and thread them through `src/lib/date-format.ts` (`Intl.DateTimeFormat(locale, { timeZone })`).
### Themes and appearance
- [ ] **Font size setting is honoured by 3 of ~90 files** — `P3` — `partial` — deferred: Text.defaultProps is gone on the new architecture; needs the static typography import replaced by useTypography() per screen
- What WEB does: `--font-size-base` on `:root` scales everything (`stores/settings-store.ts:1264-1274`).
- What RN does: `useTypography()` (`RN: src/theme/dynamic.ts:47-68`) is used by EmailListScreen, SidebarDrawer, CalendarSidebarDrawer; the other screens spread the static `typography` from `tokens.ts`, so Small/Large changes almost nothing (the thread reader, compose, settings, calendar, contacts all stay fixed).
- Fix hint: either make `tokens.typography` a hook-backed getter or, cheaper, set `Text.defaultProps`/`maxFontSizeMultiplier` and scale via `allowFontScaling` + a root `PixelRatio` factor.
- [x] **`animationsEnabled` ignored by most animations; no reduce-motion respect** — fixed in 3ae9bbe — `P3` — `partial` — useShouldAnimate ORs in AccessibilityInfo.isReduceMotionEnabled; the 18 fixed-duration Animated.timing calls in other areas' files still need useAnimDuration
- What WEB does: `--transition-duration: 0s` when off (`stores/settings-store.ts:1322-1331`) and `@media (prefers-reduced-motion: reduce)` (`app/globals.css:681`).
- What RN does: 18 files call `Animated.timing`/`LayoutAnimation` with literal durations (e.g. `UndoSnackbar.tsx:32-42`, every sheet, `SwipeableRow.tsx`); `useAnimDuration` used in 3. `AccessibilityInfo.isReduceMotionEnabled` is never consulted.
- Fix hint: route durations through `useAnimDuration` and OR in `AccessibilityInfo.isReduceMotionEnabled()` inside `useShouldAnimate`.
- [x] **Density preview and other hard-coded dark colours break on the light theme** — fixed in 22697fd — `P3` — `rn-only-bug` — Appearance/Themes/SidebarApps/settings-section/OfflineBanner/SettingsScreen badge use tokens; Plugins pane replaced
- What WEB does: preview uses tokens (`components/settings/appearance-settings.tsx:21-64`).
- What RN does: `AppearanceSettings.tsx:63` paints read subjects `rgba(250,250,250,0.8)` and `:193` preview text `rgba(161,161,170,0.7)` regardless of theme, so the preview is near-invisible on light. Similar literals: `PluginsSettings.tsx:27-30`, `ThemesSettings.tsx:108`, `SidebarAppsSettings.tsx:86,238,296`, `OfflineBanner.tsx:43` (`#a16207`), `SettingsScreen.tsx:382`.
- Fix hint: replace with `c.textSecondary`/`c.mutedForeground`/`c.warningBg` tokens.
- [x] **`userInterfaceStyle: 'dark'` likely defeats the "System" theme on iOS** — fixed in 5802cae — `P3` — `bug` (verify) — userInterfaceStyle automatic + light/dark splash
- What WEB does: `system` follows `prefers-color-scheme` (`stores/theme-store.ts:53-56`).
- What RN does: `RN: app.config.js:33` sets `userInterfaceStyle: 'dark'`, which prebuild writes as `UIUserInterfaceStyle = Dark` in Info.plist, so `useColorScheme()` (`src/theme/colors.ts:16`, `App.tsx:220`) always returns `dark` and "System" behaves like "Dark". On Android without `expo-system-ui` (not in `package.json`) the key is ignored with a prebuild warning, so Android is probably fine. Splash background is also dark-only (`:38`).
- Fix hint: set `userInterfaceStyle: 'automatic'` and give the splash a light variant (`splash.dark`).
- [x] **Themes tab is a stub with fake themes; `activeThemeId` never applied** — fixed in fe45a44 — `P3` — `missing` (record as N/A for now) — Qui/Nord/Catppuccin/Solarized token sets generated from lib/builtin-themes.ts (src/theme/builtin-themes.ts); useColors() applies activeThemeId; upload button removed
- What WEB does: 6 built-in themes (`lib/builtin-themes.ts:879-936`), zip upload, marketplace, admin forced/default theme, compiled CSS tokens, PWA theme-color meta (`stores/theme-store.ts`).
- What RN does: `ThemesSettings.tsx:18-22` lists `Default/Qui/Sepia` (Qui exists in WEB; Sepia does not), stores `activeThemeId` that nothing reads, "Upload .zip" has no handler (`:81-83`); the tab is marked Experimental.
- Fix hint: minimal viable subset = map WEB's built-in theme token sets (`builtin-themes.ts` light/dark `colors`) onto `ThemePalette` and let `useColors()` pick `activeThemeId`; remove the upload button. Otherwise hide the tab.
- [ ] **Status bar / navigation bar theming partial** — `P3` — `partial` — deferred: expo-navigation-bar / expo-system-ui are not installed; add one and call setBackgroundColorAsync(c.background) on theme change
- What WEB does: theme-color meta follows active theme (#671).
- What RN does: `StatusBar style` is derived (`App.tsx:219-223`) but the Android navigation bar (edge-to-edge is on, `app.config.js:56`) is never themed, and the bottom tab bar/`SafeAreaView` colours come from `useColors` so they are fine.
- Fix hint: `expo-navigation-bar`/`SystemUI.setBackgroundColorAsync(c.background)` on theme change.
### Updates
- [x] **No severity concept; security/deprecated updates are dismissable** — fixed in 431face — `P3` — `partial` — severity:/advisory: lines in the release body
- What WEB does: server-side check against `version.telemetry.bulwarkmail.org` with `severity` (normal/security/deprecated), red non-dismissable banner for security/deprecated, stale cached status discarded after upgrade (`lib/version-check/sender.ts:86-96`, `stores/update-store.ts:86-122`, commit 39dcc6b2).
- What RN does: GitHub Releases `latest` only (`RN: src/api/updates.ts:57-91`); banner dismissed per tag (`UpdateBanner.tsx:21`, `updates-store.ts:136-146`); no notion of a mandatory update; no advisory link.
- Fix hint: have the version server add a `native` channel (or read a `severity:` line from the release body) and make the banner non-dismissable when `security`/`deprecated`.
- [x] **Update check hits the GitHub API unauthenticated and blocks on companion downloads** — fixed in 431face — `P3` — `rn-only-bug`
- What RN does: `fetchLatestRelease` calls `api.github.com` (60 req/h/IP) and then downloads the `.apk.sha256` asset on every check (`RN: src/api/updates.ts:44-55,74-81`); on rate-limit the store shows `GitHub API 403` under "Last checked" with no backoff other than the 6 h interval.
- Fix hint: treat 403/429 as "skip until next interval" without surfacing an error, fetch the checksum only when the user taps Install.
- [x] **Release notes rendered raw** — fixed in 431face — `P3` — `partial`
- What RN does: `cachedLatest.body` shown as plain text, 20 lines (`UpdatesSettings.tsx:187-194`); markdown headings/links appear literally.
- Fix hint: strip markdown or use a tiny renderer; link to `htmlUrl`.
### Plugins, extensions, S/MIME, sidebar apps (mostly N/A / out of scope for now)
- [x] **Plugins tab is a non-functional stub** — fixed in 3d0d440 — `P3` — `missing` (N/A for now) — tab hidden; pane is an explainer
- What WEB does: sandboxed iframe plugin runtime, marketplace, signing, admin approval, ~40 hooks/API methods (changelog 1.5.x-1.9.x Plugins entries).
- What RN does: `PluginsSettings.tsx:41` starts with `plugins = []`, "Upload .zip" has no handler (`:176-178`), `pluginEnabled` map persists toggles for nothing.
- Fix hint: out of scope; hide the tab (or keep it as an "install from webmail" explainer). Minimal viable subset would be theme-only plugins (token sets) once the Themes finding lands.
- [x] **S/MIME tab is a stub** — fixed in 3d0d440 — `P3` — `missing` (N/A for now)
- What WEB does: S/MIME moved out of core into a privileged crypto plugin (changelog 1.6.0 "Breaking"); core exposes key/cert management on the Security tab.
- What RN does: `SmimeSettings.tsx:32-33` uses empty `MOCK_KEYS`/`MOCK_CERTS`; Import buttons have no handlers (`:118-120,163-165`); three toggles persist unused prefs; the tab is labelled implemented and lives under Privacy.
- Fix hint: mark `implemented: false` in `SettingsScreen.tsx:97` until a native crypto path exists.
- [ ] **Sidebar apps: settings exist, apps never rendered; no admin defaults (#931)** — `P3` — `partial` — deferred: useMobileSidebarApps()/openSidebarApp() in src/lib/sidebar-apps.ts (0e63d45) ready for SidebarDrawer (mail-list agent's file); admin defaults N/A
- What WEB does: apps render in the navigation rail (`components/layout/navigation-rail.tsx:391` filters `showOnMobile`), inline iframe or new tab, `keepAppsLoaded`, drag reorder, plus operator-pinned apps merged from policy (`lib/sidebar-apps.ts:103-112`, commit 4fe5701b).
- What RN does: `SidebarAppsSettings.tsx` adds/edits/removes entries into `sidebarApps` (`settings-store.ts:502-523`) but `SidebarDrawer.tsx` never reads them (grep: no usage outside settings); reorder handle is decorative (`:72`).
- Fix hint: minimal viable subset = list `sidebarApps.filter(showOnMobile)` at the bottom of `SidebarDrawer`, open with `expo-web-browser` (`openMode:'tab'`) or a `react-native-webview` screen (`inline`); admin defaults are N/A without a policy source (could be read from a webmail `/api/config` if the webmail origin becomes known).
### Misc UI
- [x] **Deep-link scheme registered but nothing handles it** — fixed in 5802cae — `P2` — `missing` — bulwarkmobile://, webmail https permalinks, mailto: (scheme + SENDTO filter) and ACTION_SEND/SEND_MULTIPLE share targets → Compose; iOS universal links still deferred
- What WEB does: permalinks for mail/calendar/contacts/files/settings with build/parse helpers (`lib/deep-links.ts:196-235,272-311,322-370,379-414`), `mailto:` opens the built-in composer, protocol handler registration, `webcal:`.
- What RN does: `scheme: 'bulwarkmobile'` (`RN: app.config.js:29`) and a `VIEW`/`BROWSABLE` intent filter (`AndroidManifest.xml:25-30`) exist, but `NavigationContainer` has no `linking` prop (`App.tsx:443`) and there is no `Linking.getInitialURL`/`addEventListener('url')` anywhere, so tapping a `bulwarkmobile://` URL merely launches the app. No `https` App Links for the webmail permalink format, no `mailto:` intent filter (tapping a mailto link in another app never offers Bulwark), no `SEND`/`SEND_MULTIPLE` share target.
- Fix hint: add a `linking` config mapping `bulwarkmobile://mail/message/:emailId`, `/calendar/event/:id`, `/contacts/:id`, `/settings/:tab` to the existing routes (`src/navigation/types.ts`), reuse WEB's path grammar so a webmail permalink can be rewritten; add `<data android:scheme="mailto"/>` and `ACTION_SEND` (`text/*`, `image/*`) filters routed to `Compose` with `prefillTo`/attachments. iOS: `CFBundleURLTypes` via `scheme` is already emitted; add `associatedDomains` for universal links later.
- [x] **No general toast system; only the email undo snackbar** — fixed in 3ae9bbe — `P3` — `partial` — src/stores/toast-store.ts + ToastHost mounted in App.tsx; Alert.alert call sites not migrated
- What WEB does: typed toasts with title/message, primary+secondary actions, progress bar, pause-on-hover, 10 s for errors (`stores/toast-store.ts`, `components/ui/toast.tsx`), used for every post-action acknowledgement (move toast shows full folder path, send-delay toast with "Send now").
- What RN does: `UndoSnackbar.tsx` is bound to `useEmailStore.pendingUndo` only; 23 files use `Alert.alert` (modal, blocking) for confirmations and errors; success acknowledgements mostly absent.
- Fix hint: generalise `UndoSnackbar` into a `toast-store` (queue, type, action) and mount one host in `App.tsx`; migrate error `Alert.alert`s that need no decision.
- [x] **No prompt dialog; Dialog is confirm-only and untranslated** — fixed in 3ae9bbe — `P3` — `partial`
- What WEB does: `components/ui/confirm-dialog.tsx` and `prompt-dialog.tsx` (text input), `useConfirmDialog` hook.
- What RN does: `Dialog.tsx:8-17` takes title/message/confirm/cancel with English defaults `'Confirm'`/`'Cancel'` (`:33-34`); text prompts are ad-hoc `TextInput`s.
- Fix hint: add an `input` prop and default the labels via `t('common.confirm')`/`t('common.cancel')`.
- [x] **Offline banner not localized; no "reconnect" retry surface** — fixed in 990cd84 — `P3` — `partial`
- What WEB does: `online` event triggers refetch (`components/mail/mail-app.tsx:269-278`, `lib/jmap/client.ts:7398`); strings come from catalogs.
- What RN does: `OfflineBanner.tsx:22-29` hard-codes "You are offline" with an English plural; network-store logic itself is sound (`network-store.ts:16-22` treats `isInternetReachable === null` as online) and App.tsx retries the session on reconnect (`:245-252`).
- Fix hint: `t('common.offline', ...)` and reuse plural helper once interpolation exists.
- [ ] **Accessibility labels almost absent** — `P3` — `partial` — done in my files (RadioGroup/Select roles, Dialog header, Settings back button, ToastHost, UpdateBanner, AccountSettings, security key rows); other areas' screens untouched
- What WEB does: aria-labels on icon buttons, `role="checkbox"`, screen-reader improvements (changelog 1.8.0 Navigation).
- What RN does: `accessibilityLabel`/`Role` appear in 8 of 94 `.tsx` files (FilesScreen 4, EmailListScreen 3, login 4, EmailThreadScreen 2, ToggleSwitch 1). Icon-only `Pressable`s in settings (`SettingsScreen.tsx:217-222` back button, `SidebarAppsSettings.tsx:91-96`, `SmimeSettings.tsx:91-106`, `UpdateBanner.tsx:40`), the tab bar badge and the `RadioGroup`/`Select` in `settings-section.tsx` have none.
- Fix hint: give every icon-only Pressable an `accessibilityLabel` + `accessibilityRole="button"`, `ToggleSwitch` `accessibilityRole="switch"` + `accessibilityState`, `RadioGroup` options `radio`.
- [x] **No haptic feedback** — fixed in 3ae9bbe — `P3` — `missing` (mobile-only nicety) — expo-haptics + src/lib/haptics.ts haptic(kind); SwipeableRow/undo wiring is the mail-list agent's (call haptic('light') at threshold, haptic('success') on undo)
- What RN does: `VIBRATE` permission exists only for the notification channel; no `expo-haptics` in `package.json`; swipe-to-action, undo and destructive confirms give no tactile feedback.
- Fix hint: `expo-haptics` `impactAsync(Light)` on swipe threshold cross and `notificationAsync(Success/Warning)` on undo/destructive.
- [x] **Version badge / "update available" tag missing from About** — fixed in 8deff66 — `P3` — `partial`
- What WEB does: About card shows version, commit and an `update: x.y.z` / `security` pill (`components/settings/about-data-settings.tsx:20-49`).
- What RN does: `AboutDataSettings.tsx:114-134` shows version+commit; update info lives only in the (Android-only) Updates tab; on iOS nothing indicates a newer build exists.
- Fix hint: reuse `useUpdatesStore.hasUpdate()` for a pill; on iOS link to TestFlight/App Store instead of Install.
## Verified at parity (do not redo)
- Per-account push registry keys, legacy key migration, in-flight coalescing, relay register/verify/active/unregister endpoint usage, 90-day expiry with 7-day refresh, relay-confirmed-dead reaping: `RN: src/lib/push-notifications.ts:16-95,271-298,342-383,411-439` match `lib/web-push.ts:16-25,334-389,448-489` and `repos/relay/README.md` endpoints.
- Notification tap routing switches account before navigating (`RN: App.tsx:58-78`), cold-start tap via `NotificationTapStore`.
- Headless task respects `emailNotificationsEnabled` and restores the active account afterwards (`push-background-task.ts:32-41,136-144`).
- Relay treats `EmailPush` bodies and forwards only ids (`repos/relay/src/payload.ts`); RN register payload shape (`subscriptionId`, `fcmToken`, `accountLabel`) matches `handleRegister`.
- Update cache staleness after upgrade (#913 analogue): RN compares `cachedLatest.tag` to the running version at read time (`updates-store.ts:150-156`), so a cached "newer" release cannot survive an upgrade. `version-compare.ts` semantics match WEB `lib/version-compare.ts`.
- APK download: size check, optional SHA-256 from companion asset or body, `REQUEST_INSTALL_PACKAGES`, installer intent (`install-update.ts`); Updates tab hidden off Android (`platform-capabilities.ts`, `SettingsScreen.tsx:145-147`); pending-APK polling every 30 s.
- Theme light/dark/system palette hook (`src/theme/colors.ts`), density/font hooks exist (`dynamic.ts`), status bar style follows theme (`App.tsx:219-223`).
- Language picker with system default, `dateFormat` preview, `timeFormat` (`LanguageSettings.tsx`); locale override persisted (`locale-store.ts`); `t()` falls back en → fallback → key.
- Confirm dialog styling mirrors WEB confirm-dialog (`Dialog.tsx:19-27`); undo snackbar timer is `createdAt`-based so re-renders do not reset it.
- Offline detection via NetInfo with reachability fallback; outbox flush on reconnect (`App.tsx:289-295`).
- Hardware back closes a settings pane (`SettingsScreen.tsx:192-199`); settings groups/tabs mirror WEB's six groups.
## N/A on mobile
- Pro interface, toolbar position, hide account switcher / rail account list, hover actions & corners, favicon unread badge, keyboard shortcuts (`hooks/use-keyboard-shortcuts.ts`), guided tour / onboarding-on-new-devices, PWA install prompt & Apple touch icons, protocol handler page (`mailto:`/`webcal:` registration is done via intent filters instead — see deep-link finding), `protocolOpenMode`, admin policy lock/hide of settings, plugin sandbox/marketplace/signing, theme zip upload, telemetry (server-side), server-side version-check scheduler, `settingsSyncEnabled` config flag, `NEXT_PUBLIC_LOCALE_PREFIX` routing, calendar hover preview, mail layout (split/focus/horizontal), resizable columns (`stores/ui-store.ts`), `SETTINGS_DATA_DIR`/`SESSION_SECRET` encryption at rest (native has no server; use OS keystore semantics instead), spam-siege easter egg.
+319
View File
@@ -0,0 +1,319 @@
# JMAP client core, live sync, offline/outbox, network & error handling, security, S/MIME & encryption-at-rest
## Summary
RN's JMAP client (`src/api/jmap-client.ts`, 615 lines) is a thin transport: session discovery, Basic/Bearer auth with proactive+reactive OAuth refresh, 401/429 mapping, capability lookups, blob URL templating. It lacks essentially every robustness layer WEB grew between 1.4.5 and 1.9.2: request deadlines (#702), `maxConcurrentRequests` back-off (#780), transient-network retry, rate-limit pause/resume, `maxObjectsInSet`/`maxSizeUpload` awareness, relative-session-URL resolution, keep-alive ping with connection-lost signalling, and first-touch serialisation (#907). Most RN API functions index `methodResponses[0][1]` without checking for `error` / `notUpdated`, which combines badly with the outbox (a rejected mutation is treated as done). Live sync is the weakest area: there is no `AppState` handling at all, `react-native-sse` never re-polls after a network error (status 0), the SSE stream captures the Authorization header once (stale after OAuth refresh), the push effect does not re-run on account switch (stream stays bound to the previous account), and a client-cert user gets no SSE and no polling fallback. Security posture is decent (SecureStore, hardened WebView, no secret logging) but has gaps: `Linking.openURL` with unvalidated schemes from contact/calendar data, `Math.random` for OAuth `state` and TOTP secret, http:// server URLs accepted, AsyncStorage mail cache in Android auto-backup, password change leaving stored credentials stale (locks the account out). S/MIME is a mock UI; encryption-at-rest is read-only; List-Unsubscribe is absent.
## Findings
### JMAP client core (request pipeline)
- [x] **No request deadline: a stalled socket hangs send/save forever (#702)** — `P1` — `bugfix-parity` — fixed in 0b1c240
- What WEB does: `timedFetch` puts a 30 s deadline on response headers (300 s for blob transfers), throws `RequestTimeoutError`, never retries a timed-out non-idempotent request (`lib/jmap/client.ts:629-634`, `786-816`, `836-846`; changelog 1.7.x "Time out stalled JMAP requests so a send can't hang forever (#702)").
- What RN does: `request()`, `fetchSession()`, `fetchBlobArrayBuffer()` call `secureFetch` with no `AbortController`/timeout (`src/api/jmap-client.ts:405-447`, `363-386`, `568-584`). RN `fetch` has no timeout of its own; iOS suspends sockets in background and hands back dead pooled connections on resume, so the composer's Send stays disabled indefinitely. Only the client-cert native path has a 30 s timeout (`src/lib/client-cert.ts:250`).
- Fix hint: wrap every fetch in `jmap-client.ts` with an `AbortController` + `setTimeout` (30 s API, 300 s blob), throw a `RequestTimeoutError` subclass, and make `isTransientNetworkError` classify it as transient only for idempotent ops (never re-send `EmailSubmission/set`).
- [x] **`maxConcurrentRequests` refusal (400 `jmap:error:limit`) is a hard failure (#780)** — `P2` — `bugfix-parity` — fixed in 0b1c240
- What WEB does: `isConcurrentRequestRefusal` + 3 jittered retries (200/400/800 ms) before failing (`lib/jmap/client.ts:462-473`, `1136-1146`; changelog "Keep the folder tree when a refresh burst hits maxConcurrentRequests (#780)").
- What RN does: any non-OK status throws `JMAP request failed: 400` (`src/api/jmap-client.ts:442-444`). A single StateChange fans out to `handleStateChange` in email, contacts and calendar stores concurrently (`App.tsx:391-396`), each of which issues 1-3 requests, so Stalwart's default ceiling of 4 is hit in ordinary use; the folder tree is preserved (`src/stores/email-store.ts:604-612`) but the refresh is dropped.
- Fix hint: port `isConcurrentRequestRefusal` and the retry loop into `JMAPClient.request()`; read the body text before deciding.
- [x] **No transient-network retry** — `P2` — `missing` — fixed in 0b1c240
- What WEB does: retries once after 1 s on a fetch `TypeError` unless already reconnecting or timed out (`lib/jmap/client.ts:836-846`).
- What RN does: `request()` propagates the raw `TypeError("Network request failed")` (`src/api/jmap-client.ts:427`); `isTransientNetworkError` recognises it (`src/lib/network-error.ts:48-63`) but only the outbox uses that.
- Fix hint: single retry for GET/idempotent bodies in `request()`; keep non-idempotent (`EmailSubmission/set`) un-retried.
- [x] **429 handling: no rate-limit state, no pause of live updates, HTTP-date `Retry-After` becomes NaN** — `P2` — `partial` — fixed in 0b1c240 (rate-limit window + `onRateLimit`; SSE/polling pause via the window check in `request()`)
- What WEB does: `setRateLimited` blocks all requests until the window ends, tears down and re-arms push, notifies UI via `onRateLimit` and a throttled toast event; `parseRetryAfter` handles seconds and HTTP-date, caps at 5 min, defaults 60 s (`lib/jmap/client.ts:820-826`, `848-852`, `7421-7486`; `stores/auth-store.ts:208-226`).
- What RN does: throws `RateLimitError(ms)` (`src/api/jmap-client.ts:436-440`) where `parseInt` of an HTTP-date yields `NaN*1000`; nobody catches it (grep: only tests). SSE reconnect (5 s), polling (5 s), outbox flush and stores keep hammering the server during the window.
- Fix hint: add `rateLimitedUntil` to the client, short-circuit `request()` while limited, close SSE/polling and re-arm after the window, expose to `OfflineBanner`.
- [x] **HTTP error body and cause not surfaced; JSON parse unguarded** — `P3` — `partial` — fixed in 0b1c240
- What WEB does: error message includes status + first 200 chars of body, JSON parse failure mapped to a clear error (`lib/jmap/client.ts:1147-1156`; changelog "Surface the underlying network error cause in passthrough failures").
- What RN does: `JMAP request failed: ${status}` and `response.json()` unguarded (`src/api/jmap-client.ts:442-446`); `loadAccount` wraps into `NetworkError` but `request()` throws bare `TypeError`.
- Fix hint: read text, include in message, wrap `JSON.parse`; throw `NetworkError` from `request()` too.
- [x] **Method-level `error` / `notUpdated` / `notDestroyed` unchecked in most RN API functions** — `P1` — `rn-only-bug` — fixed in ccbe67c (mail/identity/push; contacts, calendar, files done by their agents)
- What WEB does: mutations inspect `notUpdated`/`notDestroyed` (`lib/jmap/client.ts:2282-2286` moveEmail; `3290-3345` sendEmail), reads check the method name before indexing (`1207-1208`).
- What RN does: `res.methodResponses[0][1].list` / `.ids` indexed blindly in `getMailboxesWithState` (`src/api/email.ts:61-70`), `getEmails` (`322-327`), `getEmailsWithState` (`338-343`), `getThread` (`428-434`), `queryEmails` (`244-260`), `getIdentities` (`src/api/identity.ts:5-12`); `Email/set` mutations never look at the response: `setEmailKeywords` (`email.ts:436-445`), `moveEmail`/`moveEmails` (`447-480`), `deleteEmails` (`483-495`), `setKeywordsForEmails` (`527-539`), `restoreEmailMailboxes`/`setEmailMailboxes` (`541-575`), `destroyEmails` (`581-586`), `updateIdentity`/`deleteIdentity` (`identity.ts:27-45`). An `['error', {type:'forbidden'}]` response makes the read path throw a `TypeError` (classified transient by `network-error.ts:51`, see outbox finding) and makes the write path report success: the outbox removes the entry (`src/stores/outbox-store.ts:199-200`), the optimistic list/cache edit is never reverted.
- Fix hint: add a shared `requireMethodResult(res, callId)` (like `resultFor` in `src/api/account-security.ts:59-67`) and an `assertSetResult(body, ids)` that throws on `notUpdated`/`notDestroyed`; use them in every `src/api/*.ts` function.
- [x] **`Email/get` and `Email/set` not split to `maxObjectsInGet`/`maxObjectsInSet`; no `getMaxObjectsInSet`** — `P2` — `bugfix-parity` — fixed in ccbe67c
- What WEB does: `batched(ids, getMaxObjectsInGet())` for gets (`lib/jmap/client.ts:713-727`), `getMaxObjectsInSet()` for `batchMarkAsRead`, `batchDeleteEmails`, `batchMoveEmails`, `batchUpdateKeywords` (`1885-1894`, `2109-2136`, `1953-1961`, `4194-4197`; `lib/jmap/request-limits.ts`; changelog "Split requests to stay inside the server's advertised limits").
- What RN does: `getEmailsWithState` (`src/api/email.ts:338-343`) is called from the incremental refresh with an unbounded `addedIds + updatedIds` list (`src/stores/email-store.ts:848-857`), `getMailboxesByIds` after `Mailbox/changes` is unbounded (`email.ts:114-127`, store `565-567`), `listScheduledEmails` fetches up to 200 ids in one get (`email.ts:922-928`); `setKeywordsForEmails`, `moveEmails`, `restoreEmailMailboxes`, `destroyEmails`, `archiveEmails` put every selected id into one `Email/set` (no `maxObjectsInSet` accessor exists in `jmap-client.ts`). Only `fetchEmailsChunked` (`email-store.ts:371-381`), `getEvents`/`getContacts` (`src/api/calendar.ts:214`, `contacts.ts:45`) and `offline-sync.ts:92` chunk. Over-limit requests fail whole, so a long offline gap (many updated ids) breaks the incremental refresh until the snapshot is dropped.
- Fix hint: add `getMaxObjectsInSet()` + a `batched()` helper to `jmap-client.ts`; apply in the listed functions.
- [x] **`maxSizeUpload` / `maxSizeRequest` ignored** — `P3` — `missing` — fixed in 0b1c240 (accessors; enforced by the files/composer agents)
- What WEB does: `getMaxSizeUpload()` (`lib/jmap/client.ts:4179-4182`) gates attachment/file uploads with a clear message (changelog 1.6.x "Use dynamic server-configured maximum upload sizes"; "maxSizeRequest").
- What RN does: no reference to `maxSizeUpload` anywhere (`grep -rn maxSizeUpload src` empty); `uploadBlob` (`src/api/blob.ts:5-62`) surfaces an opaque `Upload failed: 413`.
- Fix hint: expose `getMaxSizeUpload()` and check before `File(uri).bytes()` in `blob.ts`/ComposeScreen.
- [x] **Relative session URLs are not resolved (37f830da)** — `P2` — `bugfix-parity` — fixed in 0b1c240 (unified-inbox.ts with the mail-list agent)
- What WEB does: `rewriteSessionUrl` rebases both absolute and relative (`/jmap/`) `apiUrl`/`downloadUrl`/`uploadUrl`/`eventSourceUrl` onto the login origin without touching `{accountId}` templates (`lib/jmap/client.ts:1091-1114`; changelog 1.9.x "Resolve relative session URLs without corrupting URI templates").
- What RN does: `rewriteSessionUrls` only rewrites when `extractOrigin(url)` matches `^https?://` (`src/api/jmap-client.ts:320-340`); a relative `apiUrl` is returned unchanged and `fetch('/jmap/')` then fails with "Network request failed". Same gap in `src/api/unified-inbox.ts:55-60`. (RN correctly avoids `new URL()` so templates are not corrupted.)
- Fix hint: in `rewrite()`, when `extractOrigin` is null and the string starts with `/`, return `serverOrigin + url`.
- [x] **Capability check uses the broad `principals` URN, not `principals:owner` (44360024)** — `P2` — `bugfix-parity` — fixed in a0ce925
- What WEB does: declares `urn:ietf:params:jmap:principals:owner` in `using` only when that exact capability is advertised in session or account capabilities (`lib/jmap/client.ts:4689-4698`, `6472`; changelog "Check the specific capability a request declares (principals:owner), not a broader one").
- What RN does: `supportsSharing()` checks `CAPABILITIES.PRINCIPALS` (`src/api/files.ts:23-25`) and then pushes `PRINCIPALS_OWNER` into `using` (`files.ts:40-43`). A server advertising `principals` but not `principals:owner` rejects every FileNode request with `unknownCapability`.
- Fix hint: check `hasCapability(CAPABILITIES.PRINCIPALS_OWNER) || accountCapabilities[...]` before adding it.
- [x] **PatchObject pointer keys not JSON-Pointer escaped (5c484af1)** — `P3` — `bugfix-parity` — fixed in ccbe67c
- What WEB does: `keywordPointer()`/`pointerToken()` escape `~` and `/` (`lib/jmap/patch-pointer.ts:39-55`); mailbox moves use a whole-map replacement (`lib/jmap/client.ts:66-74`).
- What RN does: `moveEmail`/`moveEmails` build `mailboxIds/${id}` pointers unescaped (`src/api/email.ts:456-457`, `473-474`). Stalwart ids never contain `/`, so exposure is limited to non-Stalwart servers; keywords are always sent as a whole map (`email.ts:436-445`), so tag names with `/` are safe.
- Fix hint: add `pointerToken()` or switch `moveEmail(s)` to the full `mailboxIds` replacement RN already uses in `restoreEmailMailboxes`.
- [x] **Send: message created directly in Sent instead of Drafts + `onSuccessUpdateEmail` (#188)** — `P2` — `bugfix-parity` — fixed in ccbe67c
- What WEB does: creates in Drafts with `$draft`, submits, and moves to Sent via `onSuccessUpdateEmail` (full `mailboxIds` replacement, `keywords/$draft: null`) so the SMTP send happens before the message lands in Sent; needed for servers that encrypt on append (`lib/jmap/client.ts:3186-3190`, `3231-3238`, `3272-3287`; changelog "File the post-send message with a full mailboxIds replacement").
- What RN does: `emailCreate.mailboxIds = { [sentMailboxId]: true }`, `keywords: { $seen: true }`, no `onSuccessUpdateEmail` (`src/api/email.ts:786-793`, `836-846`). With Stalwart encryption-at-rest + `encryptOnAppend` the copy is encrypted before `EmailSubmission/set` reads it; and if the submission fails, a never-sent copy sits in Sent.
- Fix hint: mirror WEB: create in Drafts (needs the drafts mailbox id), `onSuccessUpdateEmail: { '#draft': { mailboxIds: {sent: true}, 'keywords/$draft': null } }`, and report `notUpdated` as a filing warning.
- [x] **Message-ID / In-Reply-To handling diverges from WEB** — `P3` — `bugfix-parity` — fixed in ccbe67c
- What WEB does: generates `messageId` client-side from the sender domain, strips angle brackets from `inReplyTo`/`references` and sends them as JMAP arrays (`lib/jmap/client.ts:546-570`, `3177-3180`, `3186-3188`; changelog "Generate the Message-ID client-side using the sender's domain").
- What RN does: relies on the server for Message-ID; uses raw `header:In-Reply-To:asText` / `header:References:asText` strings (`src/api/email.ts:823-826`).
- Fix hint: port `generateMessageId` + `stripMessageIdBrackets`, use `inReplyTo`/`references` arrays.
- [x] **Dead duplicate `src/api/submission.ts` with zero error checking** — `P3` — `rn-only-bug` — fixed in 42ab246
- What RN does: `submission.ts:140-199` is an older `sendEmail` that ignores `notCreated`/errors; no importers (`grep api/submission` empty). Risk is someone wiring it back in.
- Fix hint: delete the file.
- [x] **No keep-alive ping / connection-lost signalling / session refresh** — `P2` — `missing` — fixed in edc26ce
- What WEB does: `Core/echo` every 30 s with exponential skip back-off, `reconnect()` on failure, `onConnectionChange` drives the "connection lost" banner and per-account `isConnected` (`lib/jmap/client.ts:1010-1075`; `stores/auth-store.ts:199-206`); basic-auth 401 re-fetches the session and re-prompts TOTP (`client.ts:862-897`).
- What RN does: no ping (`grep Core/echo src` empty); connectivity comes only from NetInfo (`src/stores/network-store.ts`), so a reachable-but-down server never surfaces, and the session document (capabilities, shared accounts, `eventSourceUrl`) is never re-fetched until restart. A basic-auth 401 throws `AuthenticationError` straight away (`src/api/jmap-client.ts:432-434`).
- Fix hint: add a 30 s `Core/echo` while foregrounded (see AppState finding) feeding `network-store.online`/`account-store.isConnected`; on 401 for basic auth re-fetch the session once before failing.
- [x] **Password change leaves the stored credential stale, then evicts the account** — `P1` — `rn-only-bug` — fixed in 032732c
- What WEB does: (same class of exposure via cookie, but the server-side passthrough re-reads; out of scope). `stores/account-security-store.ts:503-526`.
- What RN does: `changePassword` succeeds (`src/api/account-security.ts:185-197`, `src/components/settings/AccountSecuritySettings.tsx:137-141`) but `JMAPClient.credentials.password` and the SecureStore entry keep the old password. The next request gets 401 -> `AuthenticationError`; on the next launch `restoreSession` clears credentials and removes the account (`src/stores/auth-store.ts:516-523`), and `retrySession` does the same (`571-587`). Push background task also starts failing.
- Fix hint: add `jmapClient.updatePassword(newPassword)` that rewrites `credentials` and persists via `setStoredCredentials`; call it after a successful `changePassword` (and after TOTP enable/disable if the password format changes).
- [x] **First-touch serialisation for Calendar/Contacts (#907)** — `P3` — `bugfix-parity` — fixed in 0b1c240
- What WEB does: `FirstTouchGate` holds later Calendar*/AddressBook*/ContactCard* requests per account until the first one settles, so clustered Stalwart doesn't create two default calendars (`lib/jmap/first-touch-gate.ts`, `lib/jmap/client.ts:1121-1128`).
- What RN does: `refetchFeatureStores` fires `fetchContacts`, `fetchCalendars`, `refresh` concurrently on every login/restore/switch (`src/stores/auth-store.ts:81-99`).
- Fix hint: port `first-touch-gate.ts` and wrap `request()`; reset it in `reset()`/`loadAccount`.
- [x] **Mailbox fetch not retried on first login (lazy provisioning, #217)** — `P3` — `bugfix-parity` — fixed in 3194b5e
- What WEB does: retries the initial mailbox fetch when a freshly created account returns no folders (changelog 1.6.x "Retry mailbox fetch on first login to handle lazy provisioning (#217)").
- What RN does: `fetchMailboxes` does one `Mailbox/get`; an empty list on a brand-new Stalwart account stays empty until pull-to-refresh (`src/stores/email-store.ts:584-588`).
- Fix hint: if the list is empty and there is no `inbox` role, retry after ~1 s up to 3 times.
- [x] **Session redirect with dropped Authorization not handled** — `P3` — `partial` — fixed in 0b1c240
- What WEB does: detects `response.redirected` + empty accounts/username and refetches the final URL with the header (`lib/jmap/client.ts:911-925`).
- What RN does: `fetchSession` accepts whatever comes back; `resolveAccountId` then throws "No account found in JMAP session" (`src/api/jmap-client.ts:363-401`). iOS strips Authorization on cross-origin redirects.
- Fix hint: same detection; retry against `response.url`.
### Live updates (SSE / polling / state changes)
- [x] **No `AppState` handling: SSE never paused in background nor recovered on foreground; no refresh on resume** — `P1` — `missing` — fixed in edc26ce
- What WEB does: `visibilitychange` -> immediate `checkForStateChanges()` + `recycleStaleSSE()`; `online` -> reconnect SSE or poll; polls pause while hidden; 90 s ping-timeout monitor (`lib/jmap/client.ts:7332-7400`, `6992-6996`; changelog #702/#781 notes).
- What RN does: `grep -rn AppState src App.tsx` is empty. The EventSource is opened once (`App.tsx:380-418`, `src/api/push.ts:120-157`). `react-native-sse` re-polls only when the XHR reaches DONE with a status (`node_modules/react-native-sse/src/EventSource.js:118-131`); on `onerror`/status 0 (socket dropped, iOS suspension, Wi-Fi to cellular) nothing schedules a reconnect (`137-149`). Result: after the first network blip, push is dead for the rest of the process lifetime with no visible sign; the app also keeps the socket (and 30 s server pings) alive while backgrounded.
- Fix hint: `AppState.addEventListener('change')`: on `background` close the EventSource; on `active` recreate it and call `fetchMailboxes()`/`refreshEmails()`/outbox `flush()`; also subscribe to `useNetworkStore` `online` transitions to recreate. Listen for the SSE `error` event and schedule a reconnect with back-off.
- [x] **SSE Authorization header captured once; stale after OAuth token refresh** — `P1` — `rn-only-bug` — fixed in edc26ce
- What WEB does: `authenticatedFetch` reads `this.authHeader` on every (re)connect, and `onTokenRefresh` retries a 401 SSE connect (`lib/jmap/client.ts:7049-7053`, `855-863`).
- What RN does: `connectEventSource` passes `{ headers: { Authorization: jmapClient.authHeader } }` at construction (`src/api/push.ts:136-138`); `react-native-sse` reuses `this.headers` on every reconnect (`EventSource.js:83-87`). After the access token expires (`ensureFreshToken` rotates it for normal requests) every SSE reconnect sends the old Bearer -> 401 -> `error` -> re-poll every 5 s forever (battery drain, server log spam, no push).
- Fix hint: on `error` with `xhrStatus === 401` close and recreate with the current `authHeader`; better, recreate the EventSource whenever `persistRefreshedTokens` runs.
- [x] **Push effect keyed on the singleton `client`: SSE stays bound to the previous account after `switchAccount`** — `P1` — `rn-only-bug` — fixed in edc26ce
- What WEB does: per-account clients; the push effect depends on `activeAccountId` and the connected-accounts signature, tearing down and re-creating streams on switch (`components/mail/mail-app.tsx:1220-1263`).
- What RN does: the effect deps are `[client, isAuthenticated]` (`App.tsx:418`); `client` is always the same `jmapClient` object (`src/stores/auth-store.ts:155`), and `isAuthenticated` stays true across `switchAccount` (`auth-store.ts:360-432`), so the effect never re-runs. The open EventSource keeps the old account's `eventSourceUrl` + credentials; its events are then dropped by `jmapClientServesActiveAccount` (`src/stores/email-store.ts:986`), and the new account gets no live updates until logout/login. The FCM setup effect already includes `activeAccountId` (`App.tsx:378`).
- Fix hint: add `activeAccountId` (and `haveLiveSession`) to the push effect deps.
- [x] **No SSE failure/fallback path (server 4xx/5xx, proxy without SSE, client cert)** — `P2` — `partial` — fixed in edc26ce
- What WEB does: non-OK SSE response -> `fallbackToPolling`; stream end -> reconnect after 3 s (rate-limit aware); `closeafter/ping` template substitution (`lib/jmap/client.ts:7049-7066`, `7129-7155`).
- What RN does: `startPolling` is used only when `eventSourceUrl` is absent (`src/api/push.ts:228-230`); no `error`/`open` listeners on the EventSource. A user with a TLS client certificate (native issue #3) is worst hit: `react-native-sse` is XHR-based and cannot present the KeyChain cert (`src/lib/client-cert.ts:40-43` explains why `fetchSecure` exists), so the SSE handshake is rejected and no polling starts -> no live updates at all.
- Fix hint: in `connectEventSource`, if `await getClientCertAlias()` is non-null skip SSE and return `startPolling(...)`; add an `error` handler that, after N consecutive non-401 failures, closes the source and falls back to polling.
- [x] **StateChange coverage: EmailSubmission, SieveScript, Identity, VacationResponse, FileNode ignored** — `P2` — `partial` — fixed in a071d05 (Sieve/vacation refetch; EmailSubmission/FileNode via `state-change-bus`)
- What WEB does: handles `Email`, `EmailSubmission` (scheduled list/metadata), `Mailbox` (any account), `Calendar`/`CalendarEvent` (+tasks), `SieveScript` (filters) (`stores/email-store.ts:3242-3310`).
- What RN does: `Email`/`EmailDelivery`/`Mailbox` (`src/stores/email-store.ts:981-1020`), `AddressBook`/`ContactCard`/`Contact` (`src/stores/contacts-store.ts:152-168`), `Calendar`/`CalendarEvent` incl. shared accounts (`src/stores/calendar-store.ts:261-283`). `ScheduledScreen`, `filter-store`, `vacation-store`, Files and Identity never refresh on push (`grep handleStateChange src/stores/filter-store.ts src/stores/vacation-store.ts` empty).
- Fix hint: add `EmailSubmission` -> scheduled list refresh (when the Scheduled screen is mounted), `SieveScript` -> `filter-store.fetch`, `VacationResponse` -> vacation store, `FileNode` -> Files screen refresh; dispatch from the `onStateChange` fan-out in `App.tsx:391-396`.
- [ ] **Contacts state changes ignore shared/group accounts** — `P3` — `partial`
- What WEB does: per-account clients; `handleStateChange` reacts to the client's own account (`stores/email-store.ts:3255-3257`); calendar/contacts stores use the account of the shared collection.
- What RN does: `contacts-store.handleStateChange` only reads `change.changed[jmapClient.accountId]` (`src/stores/contacts-store.ts:154-156`) although address books from shared accounts are shown; `calendar-store` already folds shared accounts in (`calendar-store.ts:263-272`).
- Fix hint: mirror the calendar store's `known` set using `addressBook.accountId`.
- [x] **Polling fallback is coarse: primary account only, fixed 5 s, no in-flight coalescing, no pause in background** — `P3` — `partial` — fixed in edc26ce
- What WEB does: polls every session account plus Calendar/CalendarEvent/SieveScript, 3 s active / 20 s slow, skips when `document.hidden`, coalesces overlapping polls (#781) (`lib/jmap/client.ts:6948-6954`, `7157-7301`).
- What RN does: `startPolling` polls `Mailbox/get` + `Email/get` for the primary account every 5 s with `setInterval`, no in-flight guard, never paused (`src/api/push.ts:163-202`); shared accounts, calendar, contacts and sieve never change under polling.
- Fix hint: reuse the WEB request shape (`mbx:<id>`/`eml:<id>` call ids), add an in-flight flag and AppState pause, 20 s cadence in fallback.
- [x] **SSE stream keeps the whole response in memory; no periodic recycle** — `P3` — `rn-only-bug` — fixed in edc26ce
- What WEB does: reads the body as a stream and discards processed chunks; recycles a silent stream after 90 s (`lib/jmap/client.ts:7070-7105`, `7332-7373`).
- What RN does: `react-native-sse` accumulates `xhr.responseText` for the life of the connection (`EventSource.js:108`, `_lastIndexProcessed`), and RN requests `{ping}=30`, `{closeafter}=no` (`src/api/push.ts:126-129`), so memory grows for every ping/event until reconnect. No ping-timeout detection either.
- Fix hint: either request `closeafter=state` (server closes after each event; library auto-reconnects) or recycle the EventSource every ~30 min / when no `ping` event arrived in 90 s (add a `ping` listener).
- [x] **Push subscription `types` include `Email` and `Mailbox`; no `emailPush` spam filter** — `P2` — `partial` — fixed in 00faceb
- What WEB does: subscribes to `EmailDelivery` only, plus an `emailPush` filter excluding `$junk`/Junk when the server advertises `urn:ietf:params:jmap:emailpush` (`lib/web-push.ts:39-52`, `lib/jmap/client.ts:8085-8142`; changelog 1.9.x "Stop sending notifications for spam").
- What RN does: `PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox']` (`src/lib/push-notifications.ts:104`); `createPushSubscription` has no `emailPush` support (`src/api/push.ts:23-57`). Every read/flag/move on any device wakes the relay -> FCM -> headless task, which then re-queries the inbox (`src/lib/push-background-task.ts:452-455`); a spam delivery still triggers a push (only the inbox `notKeyword $seen` query hides it, at the cost of a wake-up).
- Fix hint: subscribe to `EmailDelivery` only; add `hasEmailPushCapability()` + `emailPush` filter as in WEB; the relay must forward it (see memory note: relay lacks EmailPush).
- [x] **Headless push task re-binds the singleton client while the UI may be running** — fixed in 1f2df5f — `P2` — `rn-only-bug`
- What WEB does: N/A (service worker has its own fetch path).
- What RN does: `processAccountForPush` calls `jmapClient.loadAccount(accountId)` for each candidate account (`src/lib/push-background-task.ts:445`) and the `finally` re-loads the active one (`438-440`, errors swallowed). HeadlessJS tasks run inside the live RN instance when the app is foregrounded, so UI requests issued during that window are sent with another account's credentials/session, and a failing final `loadAccount` leaves `session = null` (`jmap-client.ts:269-270`) until the next network event.
- Fix hint: use a detached fetch path like `src/api/unified-inbox.ts:116-131` (credentials read from SecureStore, own `apiUrl`) instead of mutating the singleton.
### Offline cache & outbox
- [x] **Outbox retries forever on errors that look transient but are permanent** — `P2` — `rn-only-bug` — fixed in 0205aa0
- What RN does: `isTransientNetworkError` treats any `TypeError` and messages containing `session expired`/`not connected` as transient (`src/lib/network-error.ts:48-63`). (a) A JMAP method error makes the unchecked read path throw `TypeError: Cannot read property 'list' of undefined` (see method-error finding) -> transient -> `recordError(..., false)` never increments `attempts` (`src/stores/outbox-store.ts:203-206`, `243-254`) -> the queue is wedged on that entry until reinstall. (b) `AuthenticationError('Session expired')` from a revoked password is also transient -> same wedge. WEB has no outbox (N/A) but never mis-classifies auth errors as transient.
- Fix hint: classify by error class (`NetworkError`, `RequestTimeoutError`, `RateLimitError`) not by `TypeError`/message; treat `AuthenticationError` as terminal and pause the queue.
- [x] **Permanently failed outbox ops are dropped silently; optimistic state never reverted** — `P2` — `rn-only-bug` — fixed in 0205aa0
- What RN does: after 5 attempts the entry is removed with `console.warn` (`src/stores/outbox-store.ts:210-216`); the list/cache was already patched optimistically (`offline-cache-store.patch`, email-store mutations) and nothing refetches, so the UI shows a move/flag the server rejected until the next full refresh; `OfflineBanner` only shows the count.
- Fix hint: keep a `failed: OutboxEntry[]` in the store, surface it in `OfflineBanner`/AboutDataSettings, and trigger `refreshEmails()` for the affected mailbox when an op is dropped.
- [x] **Interrupted flush is not resumed until the next online transition or launch** — `P3` — `rn-only-bug` — fixed in 0205aa0
- What RN does: `flush()` breaks on the first transient error (`src/stores/outbox-store.ts:203-206`); the only triggers are `haveLiveSession` and NetInfo `online` flips (`App.tsx:289-295`). A blip that NetInfo never reports (server hiccup) leaves the queue idle.
- Fix hint: schedule a retry with back-off after a transient break; also flush on AppState `active`.
- [x] **Offline `onlineRun` fallback silently changes semantics (archive auto-foldering)** — `P3` — `rn-only-bug` — fixed in 0205aa0
- What RN does: `applyOrQueueBatch` runs the richer `onlineRun` (e.g. year/month archive foldering) and on transient failure re-queues the plain `mailboxes` primitives (`src/stores/outbox-store.ts:279-292`), so the replay lands in the top-level Archive without the year/month folder the user configured.
- Fix hint: encode the folder creation into the queued op (or queue an `archive` op kind that re-runs the foldering logic on replay).
- [x] **`Email/changes.hasMoreChanges` ignored in the incremental refresh** — `P3` — `partial` — fixed in f07e039
- What WEB does: WEB refetches the page instead of diffing, so it is not exposed.
- What RN does: `getEmailChanges` returns `hasMoreChanges` (`src/api/email.ts:355-376`) but `refreshEmails` uses `updated`/`destroyed` once (`src/stores/email-store.ts:836-846`); after a long offline gap the truncated lists leave stale keywords on some rows until the snapshot is invalidated. (The mailbox path does drain, `email-store.ts:575-579`, `601`.)
- Fix hint: loop while `hasMoreChanges`, or drop the snapshot and fall back to the full re-query when it is set.
- [x] **Offline cache + outbox land in Android auto-backup** — `P2` — `missing` (security) — fixed in 7f9f5af
- What WEB does: N/A (no persistent mail cache).
- What RN does: `android:allowBackup="true"` with `@xml/secure_store_backup_rules` from the expo-secure-store plugin, which only excludes SecureStore's own prefs (`android/app/src/main/AndroidManifest.xml:16`); the AsyncStorage SQLite DB (full cached bodies `webmail:offline-cache:entry:v2:*`, outbox, settings, account registry) is backed up to the user's Google account.
- Fix hint: add an app-owned `fullBackupContent`/`dataExtractionRules` excluding `databases/RKStorage` (AsyncStorage) or set `allowBackup=false` via an Expo config plugin.
- [ ] **Send has no offline path and no draft fallback** — `P2` — `missing`
- What WEB does: no offline queue either, but send failures leave the composer open with the draft autosaved (composer agent's area); send itself has a deadline (#702).
- What RN does: `ComposeScreen` shows `Alert('Send failed')` and keeps the modal (`src/screens/ComposeScreen.tsx:975-979`); there is no draft API in `src/api/email.ts` (`grep -n '[Dd]raft' src/api/email.ts` only finds the send create id), so the body is lost if the user leaves, and an offline send is not queued although the outbox exists.
- Fix hint: either add an `outbox` `send` op kind (blob uploads must already be done) or at minimum persist the composer state locally on failure.
### Security hardening (RN)
- [x] **`Linking.openURL` with unvalidated URIs from server data (contacts, calendar invitations)** — `P2` — `rn-only-bug` — fixed in 96272b6 (contacts 63bf392, calendar/invitation links confirm the host)
- What WEB does: links are sanitized in the viewer; iCal/vCard URLs open via anchor with `rel=noopener` (viewer agent's area); `isValidUnsubscribeUrl` gates unsubscribe links (`components/email/unsubscribe-banner.tsx:63`).
- What RN does: `openUrl(uri)` on a contact's stored URL (`src/screens/ContactDetailScreen.tsx:173-175`) and `Linking.openURL(videoUri)` on `virtualLocations[0].uri` from an incoming invitation (`src/components/calendar/EventDetailSheet.tsx:169`, `234`; `src/components/email/CalendarInvitationBanner.tsx:92`, `159`) accept any scheme: `intent://...#Intent;component=...;end` launches arbitrary exported activities on Android, `file://`, `content://`, or third-party deep links. EmailBodyView already restricts to `https?|mailto|tel|sms` (`src/components/EmailBodyView.tsx:715-723`).
- Fix hint: a shared `openExternalUrl()` that allows only `https?:`, `mailto:`, `tel:`, `sms:` and shows the host in a confirmation for invitation links.
- [x] **Password handoff over a custom-scheme redirect (`bulwarkmobile://…#password=`) is interceptable; OAuth `state` uses `Math.random`** — `P1` — `rn-only-bug` — fixed in 2c0dbd1 (CSPRNG state + endpoint validation; moving the password out of the fragment is a webmail change)
- What WEB does: the mobile handoff page redirects with credentials in the fragment (`lib/deep-link-handoff.ts`, changelog "Mobile handoff page with JMAP authentication verification").
- What RN does: the `password` flow returns the clear-text password in the redirect fragment (`src/lib/oauth.ts:92-99`); the scheme is a plain `intent-filter` (`AndroidManifest.xml:25-30`), which any installed app can also register (no App Links verification), so a malicious app can receive the redirect. `state` is 16 bytes of `Math.random` (`oauth.ts:41-47`) - it prevents a forged callback from being *accepted* only if unpredictable; and the fragment's `server_url`, `token_endpoint`, `client_id` are trusted as-is (`oauth.ts:101-121`), so a forged callback can point refresh traffic at an attacker host.
- Fix hint: never send the password in the redirect (mint short-lived tokens or a one-time pairing code like `redeemPairingCode`, `oauth.ts:165-208`); generate `state` with `expo-crypto` `getRandomBytes`; validate `server_url`/`token_endpoint` are https and the token endpoint host matches the server host.
- [x] **`http://` server URLs accepted without a warning; cleartext then fails opaquely** — `P2` — `missing` — fixed in ce97232
- What WEB does: warns when the JMAP URL points at a local-only host (changelog "Setup: Warn when the JMAP URL points at a local-only host"); Basic auth travels server-to-server.
- What RN does: `normalizeServerInput` accepts `http` (`src/lib/server-discovery.ts:28-30`), QR payloads accept `http` (`src/lib/oauth.ts:144`, `155`), `connect()` never checks the scheme. Android has no `usesCleartextTraffic` (`app.config.js` has no `android.usesCleartextTraffic`; manifest has none) so http fails with "Network request failed" on API 28+, while iOS ATS may or may not allow it, so the user gets an unexplained error - or, worse, Basic credentials in clear on iOS.
- Fix hint: reject `http://` in `normalizeServerInput` except `localhost`/RFC-1918 in dev builds, with a clear message.
- [x] **Weak randomness for TOTP secret / UUIDs (falls back to `Math.random`)** — `P2` — `rn-only-bug` — fixed in 2c0dbd1
- What WEB does: server-side `otpauth` `Secret({size:20})` from Node crypto; `generateUUID()` guarded (changelog "Replace unguarded crypto.randomUUID() with safe generateUUID()").
- What RN does: `randomBytes` tries `crypto.getRandomValues`, then `crypto.randomUUID`, then `Math.random` (`src/lib/totp.ts:404-437`). `expo-crypto` is not a dependency (`package.json`), Hermes exposes no `crypto` global by default, so a long-lived 2FA secret is minted from `Math.random`; the same fallback is documented for `lib/uuid.ts` and `lib/oauth.ts`.
- Fix hint: add `expo-crypto` and use `Crypto.getRandomBytes(20)` / `Crypto.randomUUID()`; remove the Math.random branches.
- [x] **Client-cert native fetch follows redirects with Authorization and turns POST into GET (#627 class)** — `P3` — `bugfix-parity` — fixed in 6f747db
- What WEB does: `postJmap` follows redirects manually, keeps POST, only to the same host or an https upgrade (`lib/stalwart/jmap-api.ts:44-93`).
- What RN does: `instanceFollowRedirects = true` on `HttpURLConnection` (`android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkClientCertModule.kt:144`); a 301 in front of `/jmap/` re-issues as GET (Stalwart answers 404 problem+json) and cross-host redirects re-send the header. The non-cert `fetch` path has the same POST->GET behaviour but that matches browser semantics WEB also lives with client-side.
- Fix hint: `instanceFollowRedirects = false`, return the 3xx to JS, and handle the redirect in `secureFetch` with the same host/https-upgrade rule.
- [x] **Client-cert path buffers whole bodies through base64 over the bridge with a 30 s cap** — `P3` — `rn-only-bug` — fixed in 6f747db (blob deadline raised to 300 s; streaming still deferred)
- What RN does: the whole response is read into memory and base64-encoded (`BulwarkClientCertModule.kt:170-190`, `src/lib/client-cert.ts:204-216`); attachment downloads for cert users go through `secureFetch(...).arrayBuffer()` (`src/lib/email-export.ts:100-110`) and uploads through `bodyToBase64` (`client-cert.ts:171-188`), so a 50 MB attachment costs ~200 MB of JS heap and can time out at 30 s (`client-cert.ts:250`; WEB uses 300 s for transfers, `lib/jmap/client.ts:634`).
- Fix hint: stream to a file in the native module (`FileOutputStream`) and return the path; raise the transfer timeout.
- [x] **Blob downloads via `File.downloadFileAsync` bypass token refresh** — `P2` — `rn-only-bug` — fixed in d2ed27f
- What WEB does: `fetchBlob` goes through `authenticatedFetch`, which refreshes a Bearer token on 401 (`lib/jmap/client.ts:4150-4157`, `855-863`).
- What RN does: `downloadInto` builds the header from `jmapClient.authHeader` without `ensureFreshToken()` and does not retry on 401 (`src/lib/email-export.ts:93-97`); after the access token expires, attachment/EML download fails until some other JMAP call has refreshed the token. `fetchBlobArrayBuffer` does refresh (`src/api/jmap-client.ts:575-581`).
- Fix hint: expose `ensureFreshToken()` publicly, call it before `downloadFileAsync`, and on 401 call `forceRefreshToken()` and retry once.
- [x] **Downloaded attachment temp files are never cleaned up** — `P3` — `rn-only-bug` — fixed in d2ed27f (launch sweep wired in App.tsx)
- What RN does: `shareAttachment`/`openAttachment` write into `Paths.cache` (`src/lib/email-export.ts:127`, `195`) and only delete when the same name is reused; the APK installer deletes on failure (`src/lib/install-update.ts:78`, `143`). Cache may be purged by the OS but shared files linger (readable to apps that were granted the URI).
- Fix hint: delete the file after the share sheet resolves, and sweep `Paths.cache` older than a day at launch.
- [x] **WebView: `data:` navigations allowed inside the message frame** — `P3` — `partial` — fixed in 06742ef
- What WEB does: message HTML is sandboxed in an `srcDoc` iframe with a CSP meta tag (changelog 1.5.x); `data:` top-level navigation is blocked by the browser.
- What RN does: `onShouldStartLoadWithRequest` returns `true` for any `data:` URL (`src/components/EmailBodyView.tsx:715-718`), so `<a href="data:text/html,...">` replaces the message with attacker HTML rendered inside the app chrome (phishing form); the WebView is otherwise well hardened (`originWhitelist=['about:blank']`, file access off, `mixedContentMode="never"`, `setSupportMultipleWindows={false}`, `incognito`, `domStorageEnabled={false}`, `EmailBodyView.tsx:675-712`).
- Fix hint: allow `data:` only for `request.isTopFrame === false` or block it entirely (the body is loaded via `source={{html}}`, not a data: URL).
- [ ] **No screenshot / recent-apps protection option** — `P3` — `missing`
- What WEB does: N/A.
- What RN does: no `FLAG_SECURE`/`expo-screen-capture` anywhere; mail content appears in the Android recents thumbnail.
- Fix hint: optional "Hide content in app switcher" setting using `expo-screen-capture` `preventScreenCaptureAsync`.
- [x] **Sender/subject of every FCM message logged to logcat** — fixed in 00faceb — `P3` — `rn-only-bug`
- What RN does: `console.log('[push] fcm message', payload.title)` in production (`App.tsx:301-303`). No credential logging found (grep for password/token/secret in console calls is empty).
- Fix hint: drop or guard with `__DEV__`.
- [x] **Custom SHA-256 implementation (verified correct) could use `expo-crypto`** — `P3` — `rn-only-bug` — fixed in n/a (kept: PKCE needs a synchronous digest; expo-crypto digest is async)
- What RN does: `src/lib/sha256.ts` is a straight FIPS 180-4 implementation (padding, 64-bit length split, unsigned hex output all correct) used only for APK checksum verification (`src/lib/install-update.ts:118-127`); verification is opt-in when the release body carries a hash.
- Fix hint: once `expo-crypto` is added (see randomness finding) replace with `Crypto.digest('SHA-256', bytes)`; keep the size check.
- [x] **TOTP helper: RFC 6238 compatible; issuer hard-coded** — `P3` — `partial` — fixed in ce97232
- What WEB does: server-verified via `x:AccountPassword/set otpAuth.otpCode`; enrolment via `otpauth` with the app/brand as issuer.
- What RN does: `generateTotpEnrolment` produces `otpauth://totp/Stalwart:<label>?secret=…&algorithm=SHA1&digits=6&period=30` (`src/lib/totp.ts:469-485`) - correct parameters, base32 without padding correct; verification is server-side (comment `totp.ts:391-396`). Issuer `Stalwart` instead of the server host/brand.
- Fix hint: use the server hostname (or configured brand) as issuer.
- [x] **Relay base URL stored unvalidated (http allowed)** — `P3` — `rn-only-bug` — fixed in f6f26f7
- What RN does: `setStoredRelayBaseUrl` only trims slashes (`src/lib/push-notifications.ts:170-176`); the relay receives the FCM token and the JMAP push-subscription URL slot. WEB's telemetry/push endpoints are validated server-side against internal hosts (changelog "Block telemetry endpoint from pointing at internal/loopback hosts").
- Fix hint: require `https://` in the settings input.
### Stalwart admin/self-service, S/MIME, encryption at rest
- [x] **Encryption at rest is read-only; no public-key management** — fixed in ddae1e6 — `P2` — `partial` — fixed in 69bedf0 (API; settings UI with the settings agent)
- What WEB does: `fetchCryptoInfo`/`updateEncryptionAtRest` with `@type` (`Disabled|Aes128|Aes256`), `publicKey`, `encryptOnAppend`, `allowSpamTraining` (spam training before encryption) and `x:PublicKey/query|get|set` CRUD with `emailAddresses`/`expiresAt` (`stores/account-security-store.ts:355-448`, `625-700`; changelog 1.7.x "Manage S/MIME and PGP public keys and configure Stalwart encryption at rest", 1.9.x "Per-account isolation for encryption at rest").
- What RN does: `fetchEncryptionType` returns only the `@type` (`src/api/account-security.ts:150-155`); shown read-only in `AccountSecuritySettings.tsx:595`. No `x:PublicKey/*`, no set path. Per-account isolation is inherent (single-session client uses `jmapClient.accountId`).
- Fix hint: port `updateEncryptionAtRest`, `fetchPublicKeys`, `createPublicKey`, `removePublicKey` into `account-security.ts` (same `STALWART_USING`) and a key list/paste screen.
- [x] **S/MIME settings screen is a mock; no S/MIME detection in the viewer** — `P2` — `missing` — fixed in 06742ef (viewer banner; settings stub hidden in 3d0d440; crypto deferred)
- What WEB does: built-in S/MIME was removed from core into the privileged crypto plugin (changelog 1.5.0 note, "S/MIME: The built-in S/MIME implementation has been removed from core"); core keeps only detection (`isSmimeEmail`, `lib/jmap/client.ts:484-505`), the range-limited blob read for signature checks (`7503-7550`), and the plugin hook surface. Per-account key isolation, legacy 3DES/PBE and self-signed detection live in the plugin.
- What RN does: `SmimeSettings` renders `MOCK_KEYS`/`MOCK_CERTS` and three settings that nothing reads (`src/components/settings/SmimeSettings.tsx:32-61`; `src/stores/settings-store.ts:203-205`, `308-310`), reachable from Settings as `encryption` (`src/screens/SettingsScreen.tsx:130`). Nothing detects `application/pkcs7-mime`/`multipart/signed` bodies, so an encrypted message shows as an empty body with a `smime.p7m` attachment.
- Fix hint: parity for RN *core* = (1) port `isSmimeEmail` and show a "signed/encrypted with S/MIME - not supported on mobile" banner, (2) remove or hide the mock section. Full sign/verify/decrypt needs WebCrypto, which Hermes lacks: it would require `react-native-quick-crypto` (OpenSSL) plus a PKCS#7/CMS parser (`pkijs` runs on WebCrypto and would need a shim), private keys in SecureStore/Keychain, and per-account isolation of the key store; there is no plugin tier in RN to host it. Treat as a separate project.
- [x] **Display name not refreshed from the Stalwart principal on login/restore/switch (#900)** — `P3` — `bugfix-parity` — fixed in b52768e
- What WEB does: refreshes the account label from `x:Account/get` on login, restore and switch (changelog 1.9.x "#900"; memory note: principal name is copied into the Identity only once).
- What RN does: `fetchPrincipal` exists (`src/api/account-security.ts:157-181`) but is only called from the security screen (`AccountSecuritySettings.tsx:595`); `addAccount` uses `displayName: username` (`src/stores/auth-store.ts:187-195`, `123-131`).
- Fix hint: call `fetchPrincipal()` (guarded by `isStalwartSupported()`) after `applyConnectedState` and `updateAccount({displayName})`.
- [x] **Native issue #47 "Account security not accessible" - likely gating** — `P3` — `rn-only-bug` (unverified) — fixed in 032732c
- What RN does: the screen hides password/TOTP for Bearer sessions (`src/api/jmap-client.ts:66-68`) and shows "not available" when `urn:stalwart:jmap` is absent (`src/api/account-security.ts:51-55`); an OAuth handoff login therefore has no password management even on Stalwart. WEB shows the same sections for OAuth users via the passthrough with stored basic credentials.
- Fix hint: confirm against the issue; consider allowing app-password/API-key management for Bearer sessions (they only need `x:*` methods, not the password).
### Newsletter unsubscribe
- [x] **List-Unsubscribe not parsed or offered** — `P2` — `missing` — fixed in d2ed27f
- What WEB does: `extractListHeaders` parses `List-Unsubscribe`/`List-Id`/`List-Help`/`List-Post` (`lib/email-headers.ts:253-300`); the banner opens http links in a new tab (no RFC 8058 one-click POST) and sends `mailto:` unsubscribes itself via JMAP (`components/email/unsubscribe-banner.tsx:63-110`; changelog 1.5.x "Send mailto: unsubscribe ourselves instead of via the OS handler").
- What RN does: `EMAIL_FULL_PROPERTIES` requests no headers (`src/api/email.ts:11-15`); no parsing, no banner (`grep -rln unsubscribe src` hits only unrelated store code).
- Fix hint: add `header:List-Unsubscribe:asText` and `header:List-Unsubscribe-Post:asText` to the full properties; port `parseUnsubscribeUrls`; for http+`List-Unsubscribe=One-Click` do the RFC 8058 POST (`application/x-www-form-urlencoded`, body `List-Unsubscribe=One-Click`) in-app, otherwise open the link; for `mailto:` reuse `sendEmail`.
### Misc network
- [x] **Unified inbox detached fetch: no timeout, no 429/`maxConcurrentRequests` handling, N parallel sessions** — `P3` — `partial` — fixed in 6ae21d5
- What WEB does: per-account clients share the retry/timeout/rate-limit pipeline; unified fetch errors are collected per account (`stores/email-store.ts:3325-3345`).
- What RN does: `jmapPost`/`fetchInboxForAccount` do a bare `secureFetch` per account and per shared account in parallel (`src/api/unified-inbox.ts:116-131`, `215-233`), swallowing shared-account errors and only mapping 401 to "Session expired".
- Fix hint: route through the same `request()` helper once it has deadlines/back-off (pass explicit credentials instead of the singleton).
## Verified at parity (brief list, so the fixer knows what NOT to redo)
- Session discovery, `primaryAccounts` selection (mail -> core -> first account), shared/group account detection (`src/api/jmap-client.ts:388-401`, `480-493` vs `lib/jmap/client.ts:988-995`, `4421-4463`).
- Per-capability account ids for Sieve and Files (`src/api/sieve.ts:20-23`, `src/api/files.ts:27-31`); calendar/contacts use the primary account on both sides in practice.
- OAuth proactive refresh with 60 s leeway, reactive refresh on 401, single-flight refresh promise, refresh-token rotation fallback (`src/api/jmap-client.ts:193-221`, `src/lib/oauth.ts:210-265`).
- Blob download/upload URL templating with `encodeURIComponent` and RN-safe string splitting (no `new URL()`), shared-account `accountId` override (`src/api/jmap-client.ts:557-566`, `src/api/blob.ts`).
- Upload response parsing accepts both `{blobId}` and `{[accountId]: {blobId}}` shapes (`src/api/blob.ts:38-61`).
- `Mailbox/changes` incremental sync with `hasMoreChanges` drain and `cannotCalculateChanges` fallback; folder tree kept on failure (#780 behaviour) (`src/stores/email-store.ts:528-612`).
- `Email/queryChanges` + `Email/changes` incremental list refresh with snapshot guard (issue #10) and full re-query fallback (`src/stores/email-store.ts:770-980`).
- `null` for patch removal, `false` stripped from `mailboxIds` maps (`src/api/email.ts:456`, `551-556`).
- FilterOperator/FilterCondition kept separate in query filters (`src/api/email.ts:225-237`).
- `getSharedMailboxes` chunks to `maxCallsInRequest` (`src/api/email.ts:84-105`); `getEvents`/`getContacts`/`fetchEmailsChunked`/offline sync chunk to `maxObjectsInGet`.
- Send: `/error`, `notCreated` checked; HOLDFOR envelope for scheduled send; FUTURERELEASE capability check (`src/api/email.ts:836-873`, `src/api/jmap-client.ts:505-531`).
- Push subscription create/verify/update/destroy with `using: [core]` and 90-day expiry refresh (`src/api/push.ts:9-114`, `src/lib/push-notifications.ts`).
- Outbox: idempotent full-state ops, per-account buckets, destroy-wins coalescing, oldest-first replay, account-switch guards (`src/stores/outbox-store.ts`).
- Offline cache: per-account buckets, index + entry keys, eviction by `receivedAt`, optimistic `patch`, abort on account switch (`src/stores/offline-cache-store.ts`); `getEmailDetail` cache fallback.
- Credentials in `expo-secure-store` (never AsyncStorage); SecureStore key sanitising; per-account keys; legacy migration.
- Client-cert module uses the platform default `X509TrustManager` (no trust-all) (`BulwarkClientCertModule.kt:216-222`).
- WebView hardening: `originWhitelist=['about:blank']`, no file/universal access, `mixedContentMode="never"`, no multiple windows, no cookies/DOM storage, `incognito`, external links limited to `https?|mailto|tel|sms` (`src/components/EmailBodyView.tsx:675-724`).
- No secrets in `console.*` output; `usesNonExemptEncryption: false` declared (`app.config.js:370`).
- Stalwart self-service via `urn:stalwart:jmap`: password change, display name, TOTP enable/disable, app passwords and API keys with IP allow-list, principal read (`src/api/account-security.ts`), with proper `error`/`notUpdated` checks.
- SHA-256 implementation correct (FIPS 180-4) and APK size + optional checksum verification (`src/lib/sha256.ts`, `src/lib/install-update.ts:100-127`).
## N/A on mobile
- Server-side SSRF/DNS-rebinding guard, endpoint allow-list, `OAUTH_ALLOW_PRIVATE_ENDPOINTS`, IPv6 transition-address checks (`lib/security/url-guard.ts`, `lib/stalwart/server-fetch.ts`; GHSA-24w9): RN talks to the server directly from the device; the only analogue is scheme validation (covered above).
- CSP `frame-src`/`connect-src`/`object-src` changes, `srcDoc` iframe sandbox, plugin bundle signing, admin dashboard, setup wizard, `SESSION_SECRET`, session-cookie slots, Stalwart JMAP passthrough (#627) and auth-context binding: RN has no proxy tier; it holds credentials itself and calls `x:*` methods directly.
- `MAX_SSE_STREAMS`/per-tab socket budget (#702), "one SSE stream per client", browser `visibilitychange`/`online` events, `document.hidden` poll pausing: RN is single-session; the equivalent concern is AppState (listed as a gap).
- Service-worker PWA caching, `blob:` object URLs, `URL.revokeObjectURL`, `window.open` for unsubscribe links.
- WEB S/MIME plugin (privileged same-origin plugin tier), `onBeforeBlobUpload` offload hooks, plugin crypto API: no plugin system in RN.
- `x-www-form-urlencoded` TOTP token exchange on the WEB server (`/api/auth/totp`): RN verifies TOTP through Stalwart's `otpCode` directly.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+821 -62
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+822 -58
View File
File diff suppressed because it is too large Load Diff
+912 -64
View File
File diff suppressed because it is too large Load Diff
+855 -91
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+857 -93
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+857 -93
View File
File diff suppressed because it is too large Load Diff
+857 -93
View File
File diff suppressed because it is too large Load Diff
+857 -93
View File
File diff suppressed because it is too large Load Diff
+855 -91
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+856 -92
View File
File diff suppressed because it is too large Load Diff
+857 -93
View File
File diff suppressed because it is too large Load Diff
+891 -127
View File
File diff suppressed because it is too large Load Diff
+751
View File
@@ -0,0 +1,751 @@
{
"advanced_search": {
"after": "After",
"before": "Before",
"no_attachment": "No attachment",
"not_starred": "Not starred"
},
"calendar": {
"agenda": {
"days_clear": "The next {count} days are clear.",
"no_upcoming": "No upcoming events"
},
"drawer": {
"my_calendars": "My calendars",
"no_calendars": "No calendars yet.",
"shared_with_me": "Shared with me",
"subscribed": "Subscribed"
},
"event_modal": {
"participant_placeholder": "Add participant by email"
},
"events": {
"tap_to_create": "Tap + to create one"
},
"form": {
"description_placeholder": "Notes, agenda…",
"location_placeholder": "Place or address",
"time": "Time"
},
"invitation": {
"accept": "Yes",
"add_error": "Could not add the event",
"add_to_calendar": "Add to calendar",
"added": "Added to calendar",
"already_in_calendar": "Already in your calendar",
"cancelled": "This event was cancelled",
"decline": "No",
"join_video": "Join video call",
"no_writable_calendar": "No writable calendar available",
"organizer": "Organizer",
"reading": "Reading invitation…",
"reply": "A participant replied to this invitation",
"response_error": "Could not send your response",
"response_sent": "Response sent",
"tentative": "Maybe",
"title": "Calendar invitation",
"trust_verified": "Sender verified"
},
"management": {
"clear_confirm": "Remove all",
"clear_description": "Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.",
"clear_title": "Remove all events?",
"delete_description": "The calendar and all of its events will be deleted. This cannot be undone.",
"delete_title": "Delete calendar?",
"description_placeholder": "Optional",
"edit_calendar": "Edit calendar",
"new_calendar": "New calendar"
},
"settings": {
"show_week_numbers_mobile_desc": "Display week numbers in the month view.",
"time_zone": "Time zone",
"time_zone_auto": "Device time zone",
"time_zone_desc": "Zone used for new events and for interpreting the calendar. \"Device\" follows the phone."
},
"share": {
"add_people": "Add people",
"error": "Failed to update sharing",
"no_matches": "No matches",
"no_principals": "Sharing is not available on this server.",
"role_custom": "Custom",
"search_placeholder": "Search by name or email",
"shared_with": "Shared with",
"title": "Share"
}
},
"common": {
"add": "Add",
"back": "Back",
"confirm": "Confirm",
"create": "Create",
"dismiss": "Dismiss",
"done": "Done",
"remove": "Remove",
"retry": "Retry",
"undo": "Undo",
"update": "Update"
},
"context_menu": {
"color_tag": "Tag",
"remove_color": "Remove tag"
},
"email_composer": {
"attach_camera": "Camera",
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"attachment_reminder_title": "Forgot an attachment?",
"attachment_too_large": "This file is larger than the server allows ({size} > {max}).",
"attachments_too_large_total": "The attachments would exceed the {max} this server allows per message.",
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
"identity_unavailable": "Identity unavailable",
"image_load_failed": "Could not load image",
"no_recipient": "(no recipient)",
"no_sent_folder": "No Sent folder found",
"permission_camera": "Camera permission is required to take a photo.",
"permission_photos": "Photo library permission is required to attach images.",
"recipient_add_contact": "Add to contacts",
"recipient_copy": "Copy address",
"recipient_move_to": "Move to {field}",
"recipient_remove": "Remove",
"save_draft": "Save Draft",
"schedule_custom": "Pick date & time…",
"schedule_in_1h": "In 1 hour",
"schedule_in_3h": "In 3 hours",
"schedule_past_body": "The scheduled time must be in the future.",
"schedule_past_title": "Pick a future time",
"schedule_tomorrow": "Tomorrow, 8:00 AM",
"schedule_tomorrow_morning": "Tomorrow morning",
"schedule_too_late_body": "That is later than this server allows. Pick an earlier time.",
"schedule_too_late_title": "Too far ahead",
"schedule_unsupported_body": "This mail server does not support scheduled send.",
"schedule_unsupported_title": "Scheduling unavailable",
"scheduled_body": "Your message will be sent at {time}.",
"scheduled_title": "Scheduled",
"send_failed": "Failed to send",
"send_now": "Send now",
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
"send_timeout_title": "No answer from the server",
"undo_send_label": "Sending in {seconds}s…",
"uploading": "Uploading...",
"validation": {
"subject_required": "Add a subject"
}
},
"email_list": {
"cross_account_move_offline": "Moving between accounts needs a connection",
"emails_archived_count": "{count, plural, one {# email} other {# emails}} archived",
"emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}",
"error": "Error",
"marked_as_spam": "Marked as spam",
"marked_as_spam_count": "{count, plural, one {# email} other {# emails}} marked as spam",
"marked_not_spam": "Marked as not spam",
"marked_not_spam_count": "{count, plural, one {# email} other {# emails}} marked as not spam",
"move_same_account": "Messages can only be moved within the same account",
"moved_to_trash": "Email moved to Trash",
"moved_to_trash_count": "{count, plural, one {# email} other {# emails}} moved to Trash",
"no_archive_folder": "Could not find an Archive folder on the server.",
"no_emails_in": "No emails in {folder}",
"no_junk_folder": "Could not find a Spam/Junk folder on the server.",
"no_mailboxes": "No mailboxes found",
"no_mailboxes_hint": "Check that your JMAP account has mail capability.",
"no_tags_defined": "No tags defined. Add tags in Settings.",
"no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration.",
"offline_nothing_cached": "No connection. Showing nothing because no mail has been cached yet.",
"open_draft_failed": "Could not open the draft.",
"outbox_discard": "Discard",
"outbox_failed": "{count} changes could not be saved to the server.",
"recent_searches": "Recent searches",
"scope_all_folders": "All folders",
"scope_this_folder": "This folder",
"search_placeholder": "Search mail...",
"showing_cached": "Showing cached mail",
"swipe_move": "Move",
"swipe_spam": "Spam",
"total": "total",
"undo_failed": "Undo failed",
"unknown_tag": "not defined here"
},
"email_viewer": {
"add_sender_to_contacts": "Add sender to contacts",
"address_actions": {
"add_contact": "Add to contacts",
"compose": "New message",
"copy": "Copy address",
"copy_failed": "Could not copy the address."
},
"attachment_actions": {
"open": "Open",
"preview": "Preview",
"save": "Save",
"share": "Share"
},
"attachment_failed": "Could not open attachment",
"authentication": {
"spoof_warning": "The sender address could not be verified - this message may be spoofed."
},
"bundle_too_large": "The attachments are too large to bundle.",
"color_tag": {
"blue": "Blue",
"green": "Green",
"none": "None",
"orange": "Orange",
"pink": "Pink",
"purple": "Purple",
"red": "Red",
"title": "Color Tag",
"yellow": "Yellow"
},
"delete_permanently_message": "This message will be deleted permanently and cannot be recovered.",
"delete_permanently_title": "Delete permanently?",
"export_failed": "Export failed",
"forwarded": "Forwarded",
"load_failed": "Failed to load email",
"preview": {
"open_external": "Open in app",
"pdf_external": "PDFs open in your PDF viewer on this device.",
"unsupported": "No preview for this file type."
},
"received_via": "to {name}",
"remove_color": "Remove tag",
"replied": "Replied",
"sender_actions": "Sender…",
"set_color": "Set tag",
"show_all_count": "Show all ({count})",
"show_less": "Show less",
"smime_unsupported": "Signed/encrypted with S/MIME - not supported on mobile",
"source_too_large": "The message is too large to display in full. Share the full source instead.",
"thread_loading": "Loading conversation...",
"unsubscribe_banner": {
"no_identity": "No sending identity available"
},
"view_dark_mode": "View in dark mode",
"view_light_mode": "View in light mode"
},
"files": {
"done": "Done",
"share_add_people": "Add people",
"share_empty": "Not shared with anyone yet",
"share_error": "Failed to update sharing",
"share_folder_hint": "Sharing a folder shares everything inside it.",
"share_no_matches": "No matches",
"share_no_users": "No other users found",
"share_remove": "Remove access",
"share_role_custom": "Custom",
"share_search_placeholder": "Search by name or email",
"shared_with": "Shared with"
},
"identities": {
"badge": {
"to": "to {identity}",
"via": "via {identity}"
}
},
"login": {
"mobile": {
"add_subtitle": "You're signed in to these already.",
"add_title": "Add another account",
"camera_allow": "Allow camera access",
"camera_disabled": "Camera access is disabled. Enable it in Settings to scan a sign-in QR code.",
"camera_needed": "Bulwark Mail needs camera access to scan a sign-in QR code.",
"choose_subtitle": "Choose how you'd like to sign in.",
"confirm_check": "Check this looks right",
"confirm_found": "Found your mail server",
"confirm_subtitle": "Next you'll sign in on your provider's own page.",
"connecting": "Connecting to {host}",
"connecting_detail": "Setting up your mailbox…",
"continue": "Continue",
"different_server": "Use a different server",
"elsewhere_title": "An account somewhere else",
"email_desc": "We'll find your mail server",
"email_step_hint": "Works with Stalwart and any other JMAP mail server.",
"email_step_subtitle": "That's usually all we need to find your mail server.",
"email_step_title": "What's your email address?",
"email_step_title_add": "What's the other address?",
"email_title": "Use my email address",
"err_bad_detail": "Check your email and password. If your account uses two-factor sign-in, create an app password in the webmail and use that here.",
"err_bad_title": "That didn't work",
"err_cert_detail": "The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.",
"err_cert_title": "Couldn't verify {host}",
"err_code_expired_detail": "Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.",
"err_code_expired_title": "That code has expired",
"err_generic_detail": "Something went wrong. Try again.",
"err_generic_title": "Sign-in failed",
"err_interrupted_detail": "The response didn't match the request we started. Try signing in again.",
"err_interrupted_title": "Sign-in was interrupted",
"err_no_server_detail": "Double-check the address, or scan a sign-in code from the webmail instead.",
"err_no_server_title": "No mail server at {host}",
"err_no_webmail_detail": "This server has no Bulwark webmail and no OAuth sign-in. Use a password (or an app password) instead.",
"err_no_webmail_title": "No sign-in page at {host}",
"err_rate_detail": "The server asked us to wait a moment. Try again shortly.",
"err_rate_detail_seconds": "The server asked us to wait. Try again in about {seconds} seconds.",
"err_rate_title": "Too many attempts",
"err_server_detail": "The server answered with an error. Try again in a few minutes.",
"err_server_title": "{host} is having trouble",
"err_totp_bad_detail": "Check your password and the current code from your authenticator app, then try again.",
"err_totp_detail": "This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.",
"err_totp_title": "Enter your two-factor code",
"err_unreachable_detail": "Check your connection and the server address, then try again.",
"err_unreachable_title": "Can't reach {host}",
"know_server": "I know my server address",
"known_server_desc": "We already know this server, so there's no setup",
"known_server_title": "Another account on {host}",
"looking_up": "Looking up your server…",
"manual": "Enter server details manually",
"notice_bad_qr": "That code isn't a Bulwark sign-in code",
"notice_bad_qr_detail": "Open Bulwark on the web, then Settings → Security → Link device to show one.",
"notice_bad_server": "That doesn't look like a server address",
"notice_bad_server_detail": "Try the address you use for webmail, like mail.example.com.",
"notice_full_email": "Enter a full email address",
"notice_full_email_detail": "Like ada@example.com. If you sign in with a username instead, use “I know my server address”.",
"notice_need_code": "Enter the 6-digit code from your authenticator app",
"notice_need_credentials": "Enter your email and password",
"notice_need_server": "We need a server address first",
"open_settings": "Open Settings",
"opening": "Opening {host}",
"opening_detail": "Finish signing in on the page that just opened. We’ll take it from there.",
"pairing": "Redeeming your sign-in code",
"pairing_detail": "Pairing this device with {host}…",
"password_subtitle": "Your password is sent to this server only, and stored in the device keychain.",
"password_title": "Sign in to {host}",
"provider_detail": "You'll type it on their page. Bulwark only stores a sign-in token, which you can revoke at any time.",
"provider_title": "Your password stays with your provider",
"qr_hint": "Settings → Security → Link device shows a code. Point the camera at it.",
"qr_hint_title": "Open Bulwark on your computer",
"qr_title": "Sign-in code",
"scan_desc": "The fastest way in if you're signed in on the web",
"scan_title": "Scan a sign-in code",
"server_hint": "Same address you type in the browser. We'll add https:// for you.",
"server_label": "Server address",
"server_not_found": "We couldn't find a server for {domain}. If you self-host, the address is the one you use for webmail.",
"server_scan_hint": "If you're not sure, scan a sign-in code instead. It carries the server address.",
"server_subtitle": "Enter the address you use to reach your webmail.",
"server_title": "What's your server address?",
"the_server": "the server",
"unencrypted": "Unencrypted connection",
"use_password": "Sign in with a password instead",
"username_label": "Email or username",
"your_server": "your server"
}
},
"offline": {
"banner": "You are offline",
"queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect"
},
"push_prompt": {
"body": "Turn on background notifications so new messages reach you while the app is closed.",
"dismiss": "Not now",
"enable": "Enable",
"title": "Get notified about new mail"
},
"scheduled": {
"cancel_body": "The message will not be delivered. A copy stays in your Sent folder.",
"cancel_failed": "Cancel failed",
"edit_failed": "Could not open the message for editing",
"keep_scheduled": "Keep scheduled",
"reschedule_failed": "Reschedule failed",
"send_now_failed": "Could not send now"
},
"settings": {
"account": {
"accounts": {
"description_mobile": "Reorder how accounts appear in the account switcher, pick the default, or sign one out.",
"limit": "Maximum of {count} accounts reached."
},
"shared_accounts": {
"description_mobile": "Group and shared accounts you can read through this account. Their filters and vacation responder are managed from the webmail."
},
"unknown": "Unknown"
},
"advanced": {
"about": {
"deprecated_update": "Update required: {version}",
"mobile_title": "Bulwark Mobile",
"security_update": "Security update: {version}",
"update_available": "Update: {version}",
"update_store_hint": "available in TestFlight / App Store"
},
"export_settings": {
"saved_to": "Saved to {path}"
},
"reset_settings": {
"done": "All settings have been restored to their defaults."
}
},
"appearance": {
"language": {
"restart_for_direction": "Restart the app to apply the new text direction.",
"system_default": "System default"
},
"toolbar_labels": {
"description_mobile": "Display text labels next to toolbar icons."
}
},
"badges": {
"experimental": "Experimental",
"not_implemented": "Not implemented",
"unavailable": "Unavailable"
},
"downloads": {
"attachment_template": {
"description_mobile": "Template used when saving or sharing an attachment."
},
"email_template": {
"description_mobile": "Template used when exporting a message as an .eml file."
},
"strip_diacritics": {
"description_mobile": "Convert accented letters to plain ASCII (ä → a)."
},
"transform_description": "Applied to every exported filename.",
"transform_title": "Filename Transform"
},
"email_behavior": {
"archive_mode": {
"no_archive": "No archive folder found.",
"reorganize_result": "Moved {moved} of {total} emails.",
"reorganizing": "Reorganizing…"
},
"attachment_click_action": {
"description_mobile": "What happens when you tap an attachment."
},
"auto_select_reply_identity": {
"description_mobile": "When replying, send from the address the message was originally sent to."
},
"plain_text_mode": {
"description_mobile": "Compose and read in plain text only."
},
"send_delay": {
"description_mobile": "Hold outgoing mail for a few seconds so you can cancel it. Requires server support."
},
"swipe_actions": {
"move": "Move to folder",
"pin": "Pin"
},
"swipe_left_action": {
"description_mobile": "Action when you drag a row from its right edge towards the left.",
"label_mobile": "Swipe left (right → left)"
},
"swipe_right_action": {
"description_mobile": "Action when you drag a row from its left edge towards the right.",
"label_mobile": "Swipe right (left → right)"
}
},
"experimental": {
"title": "Experimental Feature"
},
"folders": {
"account": "Account",
"cannot_delete": "Cannot delete",
"description_mobile": "Create, rename, move and delete folders. Long-press a folder in the drawer for quick actions.",
"edit_folder": "Edit \"{name}\"",
"folder_name": "Folder name",
"move_under": "Move under",
"name_placeholder": "Receipts",
"name_required": "Name required",
"new_folder": "New folder",
"not_empty_message": "\"{name}\" contains {count} emails. Delete anyway?",
"not_empty_title": "Folder not empty",
"own_account": "My folders",
"role": "Special use",
"role_hint": "Assign a special-use role (Archive, Junk, …) to this folder. Each role can be held by one folder.",
"save_failed": "Save failed",
"system_folder_delete": "System folders cannot be removed.",
"system_folder_rename": "System folders cannot be renamed.",
"top_level": "Top level"
},
"identities": {
"cannot_delete": "The primary identity cannot be removed.",
"cannot_delete_title": "Cannot delete",
"count": "{count, plural, =0 {No identities} one {# identity} other {# identities}}",
"create": "New identity",
"delete_confirm": "Remove \"{name}\"?",
"delete_failed": "Delete failed",
"delete_title": "Delete identity",
"description_mobile": "Manage sender names, email addresses, and signatures. Tap a row to edit.",
"display_name": "Display name",
"edit": "Edit identity",
"email_address": "Email address",
"email_locked": "JMAP does not allow changing an identity's email; create a new one instead.",
"invalid_email": "Enter a valid email address for this identity.",
"invalid_email_title": "Invalid email",
"new": "New",
"no_name": "(no name)",
"primary": "primary",
"save_failed": "Save failed",
"text_signature": "Plain-text signature"
},
"keywords": {
"description_mobile": "Colored tags to organize your mail.",
"discover": {
"add_all": "Add all",
"found": "{count} tags found that are not defined here.",
"none": "No unknown tags found in {scanned} messages.",
"partial": "(The scan stopped at the newest messages only.)",
"progress": "Scanned {scanned} of {total} messages…",
"scan": "Find tags in mailbox"
},
"reset_confirm": "Replace your tag list with the default tags? Tags already set on messages stay on the server.",
"reset_defaults": "Reset to Defaults"
},
"layout": {
"description": "Tune the email list interactions for mobile.",
"quick_actions": {
"description": "The three buttons shown between Prev/Next at the bottom of an open email. Defaults to Reply, Reply All and Forward.",
"label": "Reader quick actions",
"mark_unread": "Mark Read/Unread",
"relocated": "{count, plural, one {{actions} is moved to the top toolbar so you can still use it.} other {{actions} are moved to the top toolbar so you can still use them.}}",
"slot": "Slot {n}",
"spam": "Spam",
"star": "Star",
"tag": "Tag"
},
"show_avatars_in_junk": "Show sender avatars in Junk",
"show_avatars_in_junk_description": "Load sender logos for messages in the Junk folder. Off keeps spam senders from being contacted.",
"show_folder_total_count": "Show total message count on folders",
"show_folder_total_count_description": "Show the total next to the unread count in the folder list.",
"swipe_mode": {
"instant": "Instant (swipe = action)",
"reveal": "Reveal (swipe, then tap)"
},
"unified_cross_account": "Unified views across accounts",
"unified_cross_account_description": "Combine every signed-in account in \"All inboxes\". Off keeps the unified views inside the active account and its shared folders."
},
"message_list_order": {
"description": "Which messages come first in the list. Newest first is the tie-breaker within each group; the header toggle still flips oldest/newest.",
"preset_chronological": "Newest first",
"preset_custom": "Custom",
"preset_starred_first": "Starred first",
"preset_tagged_first": "Tagged first",
"preset_unread_first": "Unread first",
"scope": "Apply to",
"scope_all": "All folders",
"scope_inbox": "Inbox only",
"tag": "Tag",
"title": "Message list order"
},
"notifications": {
"email": {
"sound_channel_hint": "Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings."
},
"push": {
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
"phase_account": "Account",
"phase_jmap": "Mail server subscription",
"phase_permission": "Notification permission",
"phase_platform": "Platform",
"phase_relay": "Relay registration",
"phase_token": "Device token (Firebase)",
"phase_verify": "Verification",
"relay_desc_mobile": "The server that delivers your notifications. Defaults to the hosted Bulwark relay; change only if you self-host. Must use https.",
"relay_invalid": "Enter a valid https:// URL",
"setup_failed": "Setup failed",
"status_android_only": "Background notifications are available on Android only."
}
},
"offline": {
"cached_count": "{count, plural, one {# message} other {# messages}}",
"clear": "Clear",
"clear_cache": "Clear cache",
"clear_message": "Remove {count, plural, one {# cached message} other {# cached messages}} ({size})?",
"clear_title": "Clear offline mail",
"days_ago": "{count} d ago",
"description": "Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.",
"downloading": "Downloading {completed}/{total}…",
"enabled": "Cache recent mail",
"enabled_desc": "When on, the app keeps message bodies for the selected window on this device.",
"hours_ago": "{count} h ago",
"just_now": "just now",
"last_sync": "Last sync {when}",
"last_sync_failed": "Last sync failed: {message}",
"max_size": "Maximum size",
"max_size_desc": "Oldest messages are removed when the cache grows past this.",
"minutes_ago": "{count} min ago",
"never": "never",
"nothing_cached": "Nothing cached yet",
"queued_changes": "{count, plural, one {# change} other {# changes}} waiting to sync",
"scanning": "Scanning recent mail…",
"sync_now": "Sync now",
"syncing": "Syncing…",
"title": "Offline mail",
"window": "Window",
"window_24h": "Last 24 hours",
"window_days": "Last {count} days",
"window_desc": "How far back to cache, measured by message receipt date."
},
"plugins": {
"mobile_description": "Plugins run inside the webmail and extend it in the browser.",
"mobile_explainer": "The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here."
},
"security": {
"client_cert": {
"active": "Active: {alias}",
"choose_another": "Choose another",
"clear": "Clear",
"clear_message": "New requests will no longer present a certificate. The certificate stays installed in Android.",
"clear_title": "Stop using client certificate?",
"description": "Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS.",
"hint": "Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.",
"not_set": "Not set",
"pick": "Pick certificate",
"pick_failed": "Pick failed",
"title": "TLS client certificate"
},
"credentials": {
"allowed_ips": "Allowed IPs (optional)",
"allowed_ips_hint": "Comma or space separated. Leave empty to allow any IP.",
"clear_expiry": "Clear expiry",
"copy_now": "Copy this now — it is shown only once. Tap and hold to select.",
"create_error": "Could not create",
"expires": "Expires (optional)",
"expires_on": "expires {date}",
"no_expiry": "No expiry",
"none": "None yet.",
"remove_error": "Could not remove",
"remove_message": "Any client using it will stop working.",
"remove_title": "Remove credential?"
},
"display_name": {
"error_title": "Update failed"
},
"encryption": {
"no_keys": "Add a public key first - encryption at rest needs one."
},
"load_error": "Failed to load security settings.",
"not_supported": "Account security management requires a Stalwart server. These features are not available for this account.",
"offline": "You are offline. Account security settings need a live connection to the server.",
"password": {
"change": "Change Password",
"changed": "Your account password was updated.",
"changed_title": "Password changed",
"description": "Change the password for this account.",
"error": "Failed to change password.",
"mismatch": "Passwords do not match.",
"toggle_visibility": "Show or hide password",
"too_short": "New password must be at least 8 characters."
},
"public_keys": {
"add_error": "Failed to add public key",
"description": "S/MIME certificates and PGP keys stored on the server. They are used for encryption at rest.",
"expires": "expires {date}",
"remove_error": "Failed to remove public key",
"remove_message": "Encryption at rest that uses this key will stop working.",
"remove_title": "Remove public key?",
"title": "Public Keys"
},
"two_factor": {
"active": "Active",
"code": "Verification code",
"code_required": "Enter the 6-digit code from your app.",
"description": "Require a one-time code at login.",
"disable": "Disable",
"disable_error": "Failed to disable two-factor authentication.",
"disable_hint": "Enter your password to turn off two-factor authentication.",
"enable_error": "Failed to enable two-factor authentication.",
"enabled_message": "You will be asked for a code at next sign-in.",
"enabled_title": "Two-factor enabled",
"enrol_hint": "Add this secret to your authenticator app, then enter the 6-digit code to confirm.",
"inactive": "Inactive",
"label": "Authenticator app",
"manual_secret": "Or enter this secret manually",
"no_app": "Could not open an authenticator app. Add the secret below manually.",
"no_app_title": "No authenticator app",
"open_app": "Add to authenticator app",
"password_required": "Enter your current password.",
"section_title": "Two-Factor Authentication"
}
},
"sidebar_apps": {
"add": "Add app",
"empty": "No apps added yet.",
"form": {
"name": "Name",
"name_placeholder": "App name",
"open_mode": "Open mode",
"show_on_mobile": "Show on mobile",
"url": "URL"
},
"keep_loaded_description_mobile": "Keep inline apps running in the background when switching away.",
"open_mode": {
"inline": "Inline",
"new_tab": "New tab",
"tab": "Tab"
}
},
"smime": {
"mobile_description": "Sign and encrypt mail with S/MIME certificates.",
"mobile_explainer": "S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here."
},
"tabs": {
"plugins": "Plugins"
},
"templates": {
"delete_title": "Delete template"
},
"themes": {
"default_description": "The default light and dark palettes.",
"default_name": "Bulwark",
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.",
"title": "Themes"
},
"vacation": {
"warnings": {
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
}
}
},
"sidebar": {
"account_error": "Needs attention",
"remove_account_hint": "Long-press to remove this account",
"remove_account_message": "Sign out of {account} on this device. Your mail stays on the server.",
"remove_account_title": "Remove account?",
"show_unread": "Show unread"
},
"unified_mailbox": {
"accounts_failed": "{count} account(s) could not be loaded"
},
"updates": {
"advisory": "Security advisory",
"advisory_desc": "Details about the issue this release fixes.",
"auto_check": "Check automatically",
"auto_check_desc": "Check for updates every few hours when the app starts.",
"available": "Update available",
"available_desc": "Tap install to download v{version} and open it with Android's package installer.",
"banner": {
"advisory": "Read the advisory",
"deprecated_subtitle": "Install v{version} to keep using the app.",
"deprecated_title": "This version is no longer supported",
"security_subtitle": "v{version} fixes a security issue. Install it as soon as possible.",
"security_title": "Security update available",
"subtitle": "v{version} is ready to install.",
"title": "Update available"
},
"building": "Update building",
"building_desc": "v{version} was published, but the APK is still being built. This page will check again every 30 seconds.",
"check": "Check for updates",
"check_again": "Check again",
"check_now": "Check now",
"checking": "Checking…",
"current_version": "Current version",
"description": "Check for new versions and install them.",
"downloading": "Downloading…",
"downloading_pct": "Downloading {pct}%",
"install": "Install",
"installing": "Installing…",
"last_checked": "Last checked",
"last_error": "Last error: {message}",
"latest_version": "Latest version",
"never": "never",
"open": "Open",
"open_github": "Open on GitHub",
"opening_installer": "Opening installer…",
"progress_of": "{written} of {total}",
"published": "Published {date}",
"rate_limited": "GitHub is rate-limiting update checks; trying again later.",
"release_notes": "Release notes",
"release_page": "Release page",
"release_page_desc": "Full release notes and downloads on GitHub.",
"release_page_pending_desc": "Watch the build progress or download the APK manually.",
"severity_deprecated": "Required",
"severity_security": "Security",
"title": "App updates"
}
}
File diff suppressed because it is too large Load Diff
+856 -92
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+857 -93
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+855 -91
View File
File diff suppressed because it is too large Load Diff
+953
View File
@@ -0,0 +1,953 @@
<title>Bulwark Mobile — Sign-in redesign</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="i-qr" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" /><path d="M14 14h3v3h-3z" /><path d="M21 14v3M21 21h-4" />
</symbol>
<symbol id="i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" /><path d="m2 7 10 6 10-6" />
</symbol>
<symbol id="i-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6" />
</symbol>
<symbol id="i-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 18-6-6 6-6" />
</symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="10" width="16" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" />
</symbol>
<symbol id="i-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18" />
</symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="m4 12 5 5L20 6" />
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" /><path d="M12 7v6M12 16.5v.5" />
</symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 6 18 18M18 6 6 18" />
</symbol>
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z" /><circle cx="12" cy="12" r="2.6" />
</symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3 5 6v6c0 4.2 2.9 7.6 7 9 4.1-1.4 7-4.8 7-9V6l-7-3Z" />
</symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14M5 12h14" />
</symbol>
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="7.5" cy="15.5" r="4.5" /><path d="m10.8 12.2 8-8M17 6l2.5 2.5M14.5 8.5 17 11" />
</symbol>
</defs>
</svg>
<style>
:root {
--page-bg: #fbfbfc;
--page-panel: #f0f3f8;
--page-ink: #14171f;
--page-ink-2: #545c6b;
--page-ink-3: #8a919f;
--page-rule: #e1e6ef;
--accent: #2f6fed;
--accent-soft: rgba(47, 111, 237, 0.09);
--app-bg: #ffffff;
--app-surface: #f8fafc;
--app-surface-2: #f1f5f9;
--app-border: #e2e8f0;
--app-text: #0f172a;
--app-text-2: #64748b;
--app-muted: #94a3b8;
--app-primary: #3b82f6;
--app-primary-bg: #dbeafe;
--app-error: #ef4444;
--app-error-bg: #fee2e2;
--app-success: #16a34a;
--frame: #d5dae5;
--frame-shadow: rgba(20, 26, 40, 0.14);
--serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif;
--sans: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--page-bg: #0a0b0d;
--page-panel: #111319;
--page-ink: #eef1f6;
--page-ink-2: #a4adbb;
--page-ink-3: #6e7684;
--page-rule: #22262f;
--accent: #74aaff;
--accent-soft: rgba(116, 170, 255, 0.12);
--app-bg: #09090b;
--app-surface: #18181b;
--app-surface-2: #1f1f23;
--app-border: #27272a;
--app-text: #fafafa;
--app-text-2: #a1a1aa;
--app-muted: #71717a;
--app-primary: #3b82f6;
--app-primary-bg: #172554;
--app-error: #ef4444;
--app-error-bg: #450a0a;
--app-success: #22c55e;
--frame: #2b303a;
--frame-shadow: rgba(0, 0, 0, 0.55);
}
}
:root[data-theme="light"] {
--page-bg: #fbfbfc; --page-panel: #f0f3f8; --page-ink: #14171f; --page-ink-2: #545c6b;
--page-ink-3: #8a919f; --page-rule: #e1e6ef; --accent: #2f6fed; --accent-soft: rgba(47,111,237,0.09);
--app-bg: #ffffff; --app-surface: #f8fafc; --app-surface-2: #f1f5f9; --app-border: #e2e8f0;
--app-text: #0f172a; --app-text-2: #64748b; --app-muted: #94a3b8; --app-primary: #3b82f6;
--app-primary-bg: #dbeafe; --app-error: #ef4444; --app-error-bg: #fee2e2; --app-success: #16a34a;
--frame: #d5dae5; --frame-shadow: rgba(20,26,40,0.14);
}
:root[data-theme="dark"] {
--page-bg: #0a0b0d; --page-panel: #111319; --page-ink: #eef1f6; --page-ink-2: #a4adbb;
--page-ink-3: #6e7684; --page-rule: #22262f; --accent: #74aaff; --accent-soft: rgba(116,170,255,0.12);
--app-bg: #09090b; --app-surface: #18181b; --app-surface-2: #1f1f23; --app-border: #27272a;
--app-text: #fafafa; --app-text-2: #a1a1aa; --app-muted: #71717a; --app-primary: #3b82f6;
--app-primary-bg: #172554; --app-error: #ef4444; --app-error-bg: #450a0a; --app-success: #22c55e;
--frame: #2b303a; --frame-shadow: rgba(0,0,0,0.55);
}
body {
background: var(--page-bg);
color: var(--page-ink);
font-family: var(--serif);
font-size: 17px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.d-wrap { max-width: 1180px; margin: 0 auto; padding: 64px 28px 96px; }
/* ---------- document typography ---------- */
.d-eyebrow {
font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--page-ink-3); margin: 0 0 18px;
}
.d-title {
font-family: var(--sans); font-weight: 700; font-size: clamp(34px, 5vw, 52px);
line-height: 1.04; letter-spacing: -0.028em; margin: 0; text-wrap: balance;
}
.d-deck {
max-width: 62ch; margin: 22px 0 0; font-size: 19px; line-height: 1.62; color: var(--page-ink-2);
}
.d-deck strong { color: var(--page-ink); font-weight: 600; }
.d-section { margin-top: 84px; }
.d-h2 {
font-family: var(--sans); font-weight: 700; font-size: 25px; letter-spacing: -0.018em;
margin: 0; text-wrap: balance;
}
.d-lede { max-width: 62ch; margin: 12px 0 0; color: var(--page-ink-2); }
.d-rule { height: 1px; background: var(--page-rule); border: 0; margin: 26px 0 34px; }
.d-meta {
display: flex; flex-wrap: wrap; gap: 8px 10px; margin-top: 30px;
font-family: var(--mono); font-size: 11.5px; color: var(--page-ink-2);
}
.d-meta span {
border: 1px solid var(--page-rule); border-radius: 999px; padding: 4px 11px; background: var(--page-panel);
}
/* ---------- comparison ---------- */
.d-compare {
display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 1px;
background: var(--page-rule); border: 1px solid var(--page-rule); border-radius: 12px; overflow: hidden;
}
.d-cell { background: var(--page-bg); padding: 20px 22px; }
.d-cell dt {
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--page-ink-3); margin: 0 0 10px;
}
.d-cell dd { margin: 0; font-family: var(--sans); font-size: 15px; line-height: 1.45; }
.d-was { color: var(--page-ink-3); text-decoration: line-through; text-decoration-thickness: 1px; }
.d-now { color: var(--page-ink); font-weight: 600; display: block; margin-top: 3px; }
/* ---------- filmstrip ---------- */
.d-strip {
display: flex; gap: 30px; overflow-x: auto; padding: 30px 26px 26px;
background: var(--page-panel); border-radius: 16px; scroll-snap-type: x proximity;
}
.d-strip figure { margin: 0; scroll-snap-align: center; flex: 0 0 auto; width: 288px; }
.d-strip figcaption { margin-top: 18px; }
.d-step {
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--accent); display: block; margin-bottom: 5px;
}
.d-step.is-alt { color: var(--page-ink-3); }
.d-cap-title { font-family: var(--sans); font-weight: 650; font-size: 15.5px; letter-spacing: -0.01em; }
.d-cap-body { font-size: 14.5px; line-height: 1.5; color: var(--page-ink-2); margin: 5px 0 0; }
/* ---------- notes ---------- */
.d-notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 22px; max-width: 74ch; }
.d-notes li { display: grid; grid-template-columns: 26px 1fr; gap: 14px; align-items: start; }
.d-notes svg { width: 18px; height: 18px; color: var(--accent); margin-top: 4px; }
.d-note-h { font-family: var(--sans); font-weight: 650; font-size: 15.5px; }
.d-note-b { color: var(--page-ink-2); font-size: 16px; }
code {
font-family: var(--mono); font-size: 0.85em; background: var(--accent-soft);
color: var(--accent); padding: 1.5px 5px; border-radius: 4px;
}
.d-foot {
margin-top: 80px; padding-top: 22px; border-top: 1px solid var(--page-rule);
font-family: var(--mono); font-size: 11.5px; color: var(--page-ink-3);
}
/* ================= phone chrome ================= */
.p {
width: 288px; height: 600px; border-radius: 36px; background: var(--frame);
padding: 8px; box-shadow: 0 18px 44px -18px var(--frame-shadow); position: relative;
}
.p-screen {
width: 100%; height: 100%; border-radius: 29px; background: var(--app-bg); overflow: hidden;
position: relative; font-family: var(--sans); color: var(--app-text); font-size: 14px; line-height: 1.4;
display: flex; flex-direction: column;
}
.p-notch {
position: absolute; top: 15px; left: 50%; transform: translateX(-50%); width: 74px; height: 5px;
border-radius: 99px; background: var(--app-border); z-index: 6;
}
.p-pad { padding: 42px 24px 22px; flex: 1; display: flex; flex-direction: column; }
.p-pad.is-centered { justify-content: center; }
/* ---- in-app primitives ---- */
.s-logo {
width: 56px; height: 56px; border-radius: 17px; background: var(--app-primary);
display: grid; place-items: center; margin: 0 auto 16px; color: #fff;
}
.s-logo svg { width: 30px; height: 30px; }
.s-brand { text-align: center; margin-bottom: 30px; }
.s-brand h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; margin: 0; }
.s-brand p { font-size: 12.5px; color: var(--app-muted); margin: 5px 0 0; }
.s-h { font-size: 21px; font-weight: 700; letter-spacing: -0.022em; margin: 0; line-height: 1.22; }
.s-sub { font-size: 13px; color: var(--app-text-2); margin: 8px 0 0; line-height: 1.45; }
.s-tile {
display: flex; align-items: center; gap: 13px; width: 100%; text-align: left;
border: 1px solid var(--app-border); background: var(--app-surface);
border-radius: 14px; padding: 14px 15px; color: inherit;
}
.s-tile.is-primary { background: var(--app-primary); border-color: var(--app-primary); color: #fff; }
.s-tile-ico { width: 22px; height: 22px; flex: 0 0 22px; opacity: 0.95; }
.s-tile-txt { flex: 1; min-width: 0; }
.s-tile-t { font-size: 14.5px; font-weight: 600; letter-spacing: -0.005em; }
.s-tile-d { font-size: 11.5px; opacity: 0.72; margin-top: 2px; }
.s-tile-chev { width: 16px; height: 16px; opacity: 0.5; }
.s-stack { display: flex; flex-direction: column; gap: 10px; }
.s-field { margin-top: 20px; }
.s-label { font-size: 12px; font-weight: 600; color: var(--app-text-2); margin-bottom: 7px; }
.s-input {
border: 1px solid var(--app-border); background: var(--app-bg); border-radius: 10px;
height: 42px; padding: 0 13px; display: flex; align-items: center; gap: 8px; font-size: 14px;
}
.s-input.is-focus { border-color: var(--app-primary); box-shadow: 0 0 0 3px var(--app-primary-bg); }
.s-input.is-error { border-color: var(--app-error); }
.s-input .s-ph { color: var(--app-muted); }
.s-input svg { width: 17px; height: 17px; color: var(--app-muted); flex: 0 0 17px; }
.s-caret {
width: 1.5px; height: 17px; background: var(--app-primary); display: inline-block;
animation: blink 1.05s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .s-caret { animation: none; } }
.s-hint { font-size: 11.5px; color: var(--app-muted); margin-top: 8px; line-height: 1.45; }
.s-hint.is-error { color: var(--app-error); }
.s-btn {
height: 46px; border-radius: 12px; background: var(--app-primary); color: #fff;
display: grid; place-items: center; font-size: 15px; font-weight: 600; margin-top: 20px;
}
.s-btn.is-off { background: var(--app-surface-2); color: var(--app-muted); }
.s-btn.is-quiet {
background: transparent; color: var(--app-text); border: 1px solid var(--app-border); font-weight: 500;
}
.s-link { text-align: center; font-size: 13px; color: var(--app-primary); margin-top: 16px; font-weight: 500; }
.s-link.is-muted { color: var(--app-muted); font-weight: 400; }
.s-back { width: 22px; height: 22px; color: var(--app-text-2); margin-bottom: 22px; }
.s-foot {
text-align: center; font-size: 11px; color: var(--app-muted); padding: 0 0 8px; line-height: 1.6;
}
.s-card {
border: 1px solid var(--app-border); background: var(--app-surface); border-radius: 14px;
padding: 15px; display: flex; align-items: center; gap: 12px; margin-top: 22px;
}
.s-card-ico {
width: 38px; height: 38px; border-radius: 11px; background: var(--app-primary-bg);
color: var(--app-primary); display: grid; place-items: center; flex: 0 0 38px;
}
.s-card-ico svg { width: 19px; height: 19px; }
.s-card-t { font-size: 14.5px; font-weight: 600; }
.s-card-d { font-size: 11.5px; color: var(--app-text-2); margin-top: 2px; display: flex; align-items: center; gap: 5px; }
.s-dot { width: 5px; height: 5px; border-radius: 99px; background: var(--app-success); display: inline-block; }
.s-note {
display: flex; gap: 9px; align-items: flex-start; margin-top: 16px;
font-size: 11.5px; color: var(--app-text-2); line-height: 1.5;
}
.s-note svg { width: 15px; height: 15px; flex: 0 0 15px; margin-top: 1px; color: var(--app-muted); }
.s-banner {
display: flex; gap: 9px; align-items: flex-start; border-radius: 11px; padding: 11px 12px;
background: var(--app-error-bg); font-size: 12px; line-height: 1.45; margin-top: 18px;
}
.s-banner svg { width: 15px; height: 15px; flex: 0 0 15px; margin-top: 1px; color: var(--app-error); }
.s-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; }
.s-divider::before, .s-divider::after { content: ""; height: 1px; flex: 1; background: var(--app-border); }
.s-divider span { font-size: 11.5px; color: var(--app-muted); }
/* progress rows (setting up) */
.s-prog { display: flex; align-items: center; gap: 11px; padding: 11px 0; font-size: 13.5px; }
.s-prog .s-tick {
width: 20px; height: 20px; border-radius: 99px; background: var(--app-success);
color: #fff; display: grid; place-items: center; flex: 0 0 20px;
}
.s-prog .s-tick svg { width: 11px; height: 11px; }
.s-prog .s-spin {
width: 20px; height: 20px; border-radius: 99px; flex: 0 0 20px;
border: 2px solid var(--app-border); border-top-color: var(--app-primary); animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .s-prog .s-spin { animation: none; } }
.s-prog.is-pending { color: var(--app-muted); }
/* account rows (add mode) */
.s-acct { display: flex; align-items: center; gap: 11px; padding: 11px 0; }
.s-av {
width: 34px; height: 34px; border-radius: 99px; display: grid; place-items: center;
font-size: 13px; font-weight: 600; color: #fff; flex: 0 0 34px;
}
.s-acct-t { font-size: 14px; font-weight: 550; }
.s-acct-d { font-size: 11.5px; color: var(--app-muted); }
/* scanner — deliberately always dark, it's a camera view */
.p-screen.is-camera { background: #0b0d10; color: #fff; }
.s-cam { position: absolute; inset: 0; background:
radial-gradient(120% 80% at 50% 30%, #2a3038 0%, #14171c 55%, #0b0d10 100%); }
.s-cam-grid { position: absolute; inset: 0; opacity: 0.18; background-image:
linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px); background-size: 34px 34px; }
.s-reticle { position: absolute; top: 168px; left: 50%; transform: translateX(-50%); width: 176px; height: 176px; }
.s-reticle i {
position: absolute; width: 34px; height: 34px; border: 3px solid #fff; border-radius: 4px;
}
.s-reticle i:nth-child(1) { top: 0; left: 0; border-right: 0; border-bottom: 0; }
.s-reticle i:nth-child(2) { top: 0; right: 0; border-left: 0; border-bottom: 0; }
.s-reticle i:nth-child(3) { bottom: 0; left: 0; border-right: 0; border-top: 0; }
.s-reticle i:nth-child(4) { bottom: 0; right: 0; border-left: 0; border-top: 0; }
.s-qr {
position: absolute; inset: 22px; border-radius: 6px; background: #fff;
display: grid; grid-template-columns: repeat(9, 1fr); grid-template-rows: repeat(9, 1fr); padding: 8px; gap: 2px;
}
.s-qr b { background: #0b0d10; border-radius: 1px; }
.s-cam-top {
position: absolute; top: 40px; left: 0; right: 0; display: flex; align-items: center;
justify-content: space-between; padding: 0 20px; z-index: 3;
}
.s-cam-top svg { width: 22px; height: 22px; color: #fff; }
.s-cam-copy { position: absolute; left: 0; right: 0; bottom: 46px; text-align: center; padding: 0 30px; z-index: 3; }
.s-cam-copy p { margin: 0; font-size: 13px; line-height: 1.5; color: rgba(255,255,255,0.82); }
.s-cam-copy .s-cam-h { font-size: 15.5px; font-weight: 600; color: #fff; margin-bottom: 6px; }
.s-cam-copy .s-cam-link { margin-top: 18px; font-size: 13px; font-weight: 500; color: #8ab4ff; }
/* browser handoff sheet */
.s-sheet {
position: absolute; inset: 26px 0 0; background: var(--app-bg); border-radius: 18px 18px 0 0;
box-shadow: 0 -8px 40px rgba(0,0,0,0.35); display: flex; flex-direction: column; overflow: hidden;
}
.s-sheet-bar {
display: flex; align-items: center; gap: 9px; padding: 13px 15px;
border-bottom: 1px solid var(--app-border); font-size: 12.5px; color: var(--app-text-2);
}
.s-sheet-bar svg { width: 13px; height: 13px; color: var(--app-success); }
.s-sheet-bar .s-url { flex: 1; color: var(--app-text); font-weight: 550; }
.s-sheet-bar .s-done { color: var(--app-primary); font-weight: 600; }
.s-sheet-body { padding: 30px 24px; flex: 1; }
.s-scrim { position: absolute; inset: 0; background: rgba(4,6,10,0.5); }
.s-web-brand { display: flex; align-items: center; gap: 9px; margin-bottom: 24px; }
.s-web-brand .s-web-mark {
width: 28px; height: 28px; border-radius: 9px; background: var(--app-primary);
color: #fff; display: grid; place-items: center;
}
.s-web-brand .s-web-mark svg { width: 16px; height: 16px; }
.s-web-brand b { font-size: 14.5px; letter-spacing: -0.01em; }
/* the "before" screen is intentionally cramped */
.s-old-field { margin-bottom: 13px; }
</style>
<div class="d-wrap">
<p class="d-eyebrow">Bulwark Mobile · sign-in redesign</p>
<h1 class="d-title">Three fields nobody knows the answer&nbsp;to</h1>
<p class="d-deck">
The current sign-in screen opens with a <strong>Server URL</strong> field. Most people don't know their
JMAP endpoint, and the two paths that don't require knowing it — the webmail handoff and the QR code — sit
below a divider, greyed out until the URL is filled in anyway. This proposes an entry screen that leads with
the easy paths and asks for a server only when we genuinely can't work it out.
</p>
<div class="d-meta">
<span>src/screens/LoginScreen.tsx</span>
<span>expo · react-native</span>
<span>light + dark, both shown</span>
</div>
<section class="d-section">
<h2 class="d-h2">What changes</h2>
<hr class="d-rule" />
<dl class="d-compare">
<div class="d-cell">
<dt>To reach the inbox</dt>
<dd><span class="d-was">3 fields typed</span><span class="d-now">1 field, or none with a code</span></dd>
</div>
<div class="d-cell">
<dt>Server URL</dt>
<dd><span class="d-was">Asked first, always</span><span class="d-now">Looked up from the address</span></dd>
</div>
<div class="d-cell">
<dt>Password in the app</dt>
<dd><span class="d-was">Default path</span><span class="d-now">Fallback only</span></dd>
</div>
<div class="d-cell">
<dt>QR sign-in</dt>
<dd><span class="d-was">Ghost button, disabled</span><span class="d-now">First option offered</span></dd>
</div>
</dl>
</section>
<section class="d-section">
<h2 class="d-h2">Today</h2>
<p class="d-lede">
Everything at once, in the least helpful order. The webmail handoff is the safer route — it never puts a
password into the app — but it's disabled until you've supplied the server, so it can't rescue anyone who's stuck.
</p>
<hr class="d-rule" />
<div class="d-strip">
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered">
<div class="s-brand">
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
<h1>Bulwark Mail</h1>
<p>Secure. Private. Yours.</p>
</div>
<div class="s-old-field">
<div class="s-label">Server URL</div>
<div class="s-input"><span class="s-ph">https://mail.example.com</span></div>
</div>
<div class="s-old-field">
<div class="s-label">Email or Username</div>
<div class="s-input"><span class="s-ph">you@example.com</span></div>
</div>
<div class="s-old-field">
<div class="s-label">Password</div>
<div class="s-input">
<span class="s-ph" style="flex:1">Enter your password</span>
<svg><use href="#i-eye" /></svg>
</div>
</div>
<div class="s-btn is-off" style="margin-top:12px">Sign In</div>
<div class="s-divider"><span>or continue with</span></div>
<div class="s-stack">
<div class="s-btn is-quiet" style="margin-top:0;opacity:0.45">Sign in via webmail</div>
<div class="s-link is-muted" style="margin-top:2px;opacity:0.45">Scan QR code</div>
</div>
</div>
<div class="s-foot">Bulwark Mobile v1.4.0<br /><span style="color:var(--app-primary)">Privacy Policy</span></div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Current build</span>
<span class="d-cap-title">One screen, six controls</span>
<p class="d-cap-body">
The two disabled controls at the bottom are the ones that would have helped. Nothing tells you where
to find a server URL, and nothing happens if you don't have one.
</p>
</figcaption>
</figure>
</div>
</section>
<section class="d-section">
<h2 class="d-h2">Proposed flow</h2>
<p class="d-lede">
One question per screen, cheapest question first. The screens below are the happy path, left to right.
</p>
<hr class="d-rule" />
<div class="d-strip">
<!-- 1 · welcome -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered">
<div class="s-brand">
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
<h1>Bulwark Mail</h1>
<p>Secure. Private. Yours.</p>
</div>
<div class="s-stack">
<div class="s-tile is-primary">
<svg class="s-tile-ico"><use href="#i-qr" /></svg>
<span class="s-tile-txt">
<span class="s-tile-t">Scan a sign-in code</span>
<span class="s-tile-d">Already signed in on the web? Fastest way in.</span>
</span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
<div class="s-tile">
<svg class="s-tile-ico"><use href="#i-mail" /></svg>
<span class="s-tile-txt">
<span class="s-tile-t">Use my email address</span>
<span class="s-tile-d">We'll find your mail server</span>
</span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
</div>
<div class="s-link is-muted">Enter server details manually</div>
</div>
<div class="s-foot">Bulwark Mobile v1.4.0<br /><span style="color:var(--app-primary)">Privacy Policy</span></div>
</div>
</div>
<figcaption>
<span class="d-step">Step 1</span>
<span class="d-cap-title">Pick a way in</span>
<p class="d-cap-body">
Two live options instead of six mixed ones. Each tile says what it costs you before you tap it.
Manual setup stays reachable in one tap — it just isn't the greeting.
</p>
</figcaption>
</figure>
<!-- 2 · email -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">What's your email address?</h2>
<p class="s-sub">That's usually all we need — we'll look up the mail server for you.</p>
<div class="s-field">
<div class="s-input is-focus">
<svg><use href="#i-mail" /></svg>
<span>ada@example.com</span><span class="s-caret"></span>
</div>
<p class="s-hint">Works with Bulwark, Stalwart, and any JMAP mail server.</p>
</div>
<div class="s-btn">Continue</div>
<div style="flex:1"></div>
<div class="s-link is-muted">I know my server address</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 2</span>
<span class="d-cap-title">One field, keyboard already up</span>
<p class="d-cap-body">
Autofocus with the email keyboard, autocorrect off. The button stays enabled and validates on tap,
so nobody is left staring at a dead grey button wondering which field is wrong.
</p>
</figcaption>
</figure>
<!-- 3 · found server -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">Found your mail server</h2>
<p class="s-sub">Next you'll sign in on your provider's own page.</p>
<div class="s-card">
<span class="s-card-ico"><svg><use href="#i-globe" /></svg></span>
<span>
<span class="s-card-t">mail.example.com</span>
<span class="s-card-d"><i class="s-dot"></i> JMAP · secure connection</span>
</span>
</div>
<div class="s-note">
<svg><use href="#i-lock" /></svg>
<span>Your password is typed on your provider's page, never into this app. Bulwark stores a token it can revoke.</span>
</div>
<div class="s-btn">Continue</div>
<div class="s-link">Use a different server</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 3</span>
<span class="d-cap-title">Confirm before the browser opens</span>
<p class="d-cap-body">
A browser jump with no warning feels like a bug. Naming the server first — and saying why the
password isn't asked for here — turns the detour into a security feature.
</p>
</figcaption>
</figure>
<!-- 4 · handoff -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered" style="opacity:0.5">
<div class="s-brand">
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
<h1>Bulwark Mail</h1>
</div>
</div>
<div class="s-scrim"></div>
<div class="s-sheet">
<div class="s-sheet-bar">
<svg><use href="#i-lock" /></svg>
<span class="s-url">mail.example.com</span>
<span class="s-done">Done</span>
</div>
<div class="s-sheet-body">
<div class="s-web-brand">
<span class="s-web-mark"><svg><use href="#i-shield" /></svg></span>
<b>Sign in to Bulwark</b>
</div>
<div class="s-label">Email</div>
<div class="s-input"><span>ada@example.com</span></div>
<div class="s-label" style="margin-top:14px">Password</div>
<div class="s-input is-focus">
<span style="flex:1">••••••••••</span><svg><use href="#i-eye" /></svg>
</div>
<div class="s-btn">Sign in</div>
<div class="s-divider"><span>or</span></div>
<div class="s-tile" style="padding:11px 13px">
<svg class="s-tile-ico"><use href="#i-key" /></svg>
<span class="s-tile-txt"><span class="s-tile-t">Continue with passkey</span></span>
</div>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 4</span>
<span class="d-cap-title">The provider's page, in a sheet</span>
<p class="d-cap-body">
This is the existing <code>runWebmailHandoff</code> flow, unchanged. It already supports whatever
the server offers — password, OAuth, passkeys, SSO — which the in-app form never will.
</p>
</figcaption>
</figure>
<!-- 5 · setting up -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered">
<div style="text-align:center;margin-bottom:26px">
<span class="s-av" style="width:60px;height:60px;font-size:22px;background:var(--app-primary);margin:0 auto 14px">AL</span>
<h2 class="s-h" style="font-size:20px">Welcome back, Ada</h2>
<p class="s-sub">ada@example.com</p>
</div>
<div>
<div class="s-prog">
<span class="s-tick"><svg><use href="#i-check" /></svg></span> Mailboxes
</div>
<div class="s-prog">
<span class="s-tick"><svg><use href="#i-check" /></svg></span> Recent mail
</div>
<div class="s-prog is-pending">
<span class="s-spin"></span> Contacts and calendars
</div>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 5</span>
<span class="d-cap-title">Land on progress, not a spinner</span>
<p class="d-cap-body">
The first sync is the slowest moment in the app. Naming what's arriving makes the wait legible, and
the inbox opens as soon as mailboxes land — the rest fills in behind it.
</p>
</figcaption>
</figure>
</div>
</section>
<section class="d-section">
<h2 class="d-h2">When it doesn't go smoothly</h2>
<p class="d-lede">
Self-hosted servers, typo'd passwords, and second accounts are the normal case for this audience, not the
exception. These four screens carry most of the redesign's weight.
</p>
<hr class="d-rule" />
<div class="d-strip">
<!-- scanner -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen is-camera">
<div class="s-cam"><div class="s-cam-grid"></div></div>
<div class="s-cam-top">
<svg><use href="#i-x" /></svg>
<span style="font-size:12.5px;opacity:0.8">Sign-in code</span>
<span style="width:22px"></span>
</div>
<div class="s-reticle">
<i></i><i></i><i></i><i></i>
<div class="s-qr">
<b></b><b></b><b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b>
<b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b>
<b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b></b><b></b>
<b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b>
<b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b style="background:transparent"></b>
<b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b>
<b></b><b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b>
<b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b>
<b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b></b>
</div>
</div>
<div class="s-cam-copy">
<p class="s-cam-h">Open Bulwark on your computer</p>
<p>Settings → Devices → Add phone shows a code. Point the camera at it.</p>
<p class="s-cam-link">Type the code instead</p>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Path A</span>
<span class="d-cap-title">The scanner tells you where the code is</span>
<p class="d-cap-body">
A scanner with no instructions is a dead end for anyone who hasn't seen the webmail's Devices page.
Redeeming a paired code skips the browser entirely — see <code>redeemPairingCode</code>.
</p>
</figcaption>
</figure>
<!-- discovery failed -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">Where does your mail live?</h2>
<p class="s-sub">We couldn't find a server for <b>example.org</b>. If you self-host, the address is the one you use for webmail.</p>
<div class="s-field">
<div class="s-label">Server address</div>
<div class="s-input is-focus">
<svg><use href="#i-globe" /></svg>
<span>mail.example.org</span><span class="s-caret"></span>
</div>
<p class="s-hint">Same address you type in the browser. We'll add <b>https://</b>.</p>
</div>
<div class="s-btn">Continue</div>
<div style="flex:1"></div>
<div class="s-note">
<svg><use href="#i-qr" /></svg>
<span>Not sure? Scan a sign-in code from the webmail instead — it carries the address with it.</span>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Path B</span>
<span class="d-cap-title">Ask for the server people know</span>
<p class="d-cap-body">
Not the JMAP session URL — the webmail address they type every day. The scheme is added for them, and
a trailing slash or full <code>/jmap</code> path is accepted and trimmed.
</p>
</figcaption>
</figure>
<!-- password fallback + error -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">Sign in to mail.example.org</h2>
<p class="s-sub">This server doesn't support the browser sign-in, so we'll ask here.</p>
<div class="s-field" style="margin-top:18px">
<div class="s-label">Email or username</div>
<div class="s-input"><span>ada@example.org</span></div>
</div>
<div class="s-field" style="margin-top:14px">
<div class="s-label">Password</div>
<div class="s-input is-error">
<span style="flex:1">••••••••</span><svg><use href="#i-eye" /></svg>
</div>
</div>
<div class="s-banner">
<svg><use href="#i-alert" /></svg>
<span><b>That password didn't work.</b> If your account has two-factor sign-in, you'll need an app password from the webmail.</span>
</div>
<div class="s-btn">Sign in</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Path C</span>
<span class="d-cap-title">Errors that name the likely cause</span>
<p class="d-cap-body">
The store's raw error string is replaced with mapped copy per failure: wrong credentials, unreachable
host, bad certificate, 2FA-needs-an-app-password. Each says what to do next.
</p>
</figcaption>
</figure>
<!-- add account -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<div style="display:flex;justify-content:flex-end"><svg class="s-back" style="margin-bottom:14px"><use href="#i-x" /></svg></div>
<h2 class="s-h">Add another account</h2>
<p class="s-sub">You're signed in to these already.</p>
<div style="margin-top:16px">
<div class="s-acct">
<span class="s-av" style="background:#3b82f6">AL</span>
<span><span class="s-acct-t">Ada Lovelace</span><span class="s-acct-d">ada@example.com</span></span>
</div>
<div class="s-acct">
<span class="s-av" style="background:#a855f7">AL</span>
<span><span class="s-acct-t">Work</span><span class="s-acct-d">a.lovelace@analytical.co</span></span>
</div>
</div>
<div class="s-divider"><span>add</span></div>
<div class="s-stack">
<div class="s-tile">
<svg class="s-tile-ico"><use href="#i-plus" /></svg>
<span class="s-tile-txt">
<span class="s-tile-t">Another account on example.com</span>
<span class="s-tile-d">Server already known</span>
</span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
<div class="s-tile">
<svg class="s-tile-ico"><use href="#i-mail" /></svg>
<span class="s-tile-txt"><span class="s-tile-t">A different provider</span></span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Add mode</span>
<span class="d-cap-title">Reuse what we already know</span>
<p class="d-cap-body">
<code>isAddMode</code> currently shows the same blank three-field form. Most second accounts live on
the same server as the first, so offer that first and skip discovery entirely.
</p>
</figcaption>
</figure>
</div>
</section>
<section class="d-section">
<h2 class="d-h2">Notes for the build</h2>
<hr class="d-rule" />
<ul class="d-notes">
<li>
<svg><use href="#i-globe" /></svg>
<div>
<div class="d-note-h">Discovery is three cheap probes, in order</div>
<p class="d-note-b">
From <code>ada@example.com</code>: try <code>https://example.com/.well-known/jmap</code>, then
<code>https://mail.example.com/.well-known/jmap</code>, then the same host the last account used.
First 200 wins. Budget the whole thing at two seconds and fall through to Path B on timeout — a
slow lookup must never be worse than the manual form.
</p>
</div>
</li>
<li>
<svg><use href="#i-shield" /></svg>
<div>
<div class="d-note-h">Nothing new on the wire</div>
<p class="d-note-b">
Every screen drives an existing store action — <code>loginViaWebmail</code>,
<code>loginViaPairing</code>, <code>login</code>. The redesign is routing and copy; the auth
surface, the state machine, and <code>parseQrLoginPayload</code> stay as they are.
</p>
</div>
</li>
<li>
<svg><use href="#i-alert" /></svg>
<div>
<div class="d-note-h">Back must always work</div>
<p class="d-note-b">
A multi-step flow earns a hardware-back handler on Android. Every step goes back one, and step 1
leaves the flow — never a trapped screen with a filled-in field you can't clear.
</p>
</div>
</li>
<li>
<svg><use href="#i-key" /></svg>
<div>
<div class="d-note-h">Let the OS help</div>
<p class="d-note-b">
<code>autoComplete="email"</code> and <code>textContentType="username"</code> on the address field,
<code>"password"</code> on the fallback field, so the platform password manager offers to fill and
save. The current screen sets neither.
</p>
</div>
</li>
</ul>
</section>
<p class="d-foot">Mockup · repos/react-native/mockups/login.html · static HTML, no interaction wired up</p>
</div>
+723 -2
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.26",
"version": "0.1.62",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -12,7 +12,9 @@
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"bump": "node scripts/bump-version.mjs",
"sync-locales": "node scripts/sync-locales.mjs"
"sync-locales": "node scripts/sync-locales.mjs",
"i18n:harvest": "node scripts/harvest-rn-keys.mjs",
"i18n:check": "node scripts/harvest-rn-keys.mjs --check"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -23,15 +25,24 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-crypto": "~15.0.9",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-haptics": "^15.0.8",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
"expo-localization": "~17.0.8",
"expo-notifications": "^0.32.17",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"postal-mime": "^3.0.0",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
+23 -5
View File
@@ -14,6 +14,18 @@ const git = (...args) =>
const gitInherit = (...args) =>
execFileSync('git', args, { cwd: root, stdio: 'inherit' });
// Pre-flight: the release is created with the GitHub CLI as the last step.
// Check it is installed and logged in BEFORE anything is committed, tagged or
// pushed - otherwise a missing login leaves a pushed tag with no release (and
// no release workflow run), which is exactly what a re-run cannot repair.
try {
execFileSync('gh', ['auth', 'status'], { cwd: root, stdio: 'ignore' });
} catch {
console.error('GitHub CLI is not available or not logged in.');
console.error('Run `gh auth login` (or set GH_TOKEN), then re-run `npm run bump`.');
process.exit(1);
}
const dirty = git('status', '--porcelain');
if (dirty) {
console.error('Working tree is not clean. Commit or stash changes before bumping.');
@@ -54,10 +66,16 @@ gitInherit('commit', '-m', `chore: update version number to ${next}`);
gitInherit('tag', '-a', next, '-m', next);
gitInherit('push', '--follow-tags');
execFileSync(
'gh',
['release', 'create', next, '--title', next, '--generate-notes'],
{ cwd: root, stdio: 'inherit' },
);
try {
execFileSync(
'gh',
['release', 'create', next, '--title', next, '--generate-notes'],
{ cwd: root, stdio: 'inherit' },
);
} catch {
console.error(`The tag ${next} is pushed but the GitHub release could not be created.`);
console.error(`Fix the GitHub CLI login and run: gh release create ${next} --title ${next} --generate-notes`);
process.exit(1);
}
console.log(`Released ${next}`);
+99
View File
@@ -0,0 +1,99 @@
// Collect t('key', 'English fallback') calls whose key exists in neither the
// vendored webmail catalog nor the RN overlay, and add them to
// locales/rn/en.json using the inline fallback as the English text.
// Run from the RN repo root: `node scripts/harvest-rn-keys.mjs [--check]`.
// `--check` only reports (exit 1 when something is missing) - used by the
// translation coverage test message.
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join, dirname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const SRC = join(ROOT, 'src');
const EN = join(ROOT, 'locales', 'en', 'common.json');
const OVERLAY = join(ROOT, 'locales', 'rn', 'en.json');
const CHECK_ONLY = process.argv.includes('--check');
function flatten(obj, prefix = '', out = new Map()) {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out);
else out.set(key, v);
}
return out;
}
function walk(dir, out = []) {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) {
if (entry === '__tests__' || entry === 'node_modules') continue;
walk(p, out);
} else if (/\.tsx?$/.test(entry)) {
out.push(p);
}
}
return out;
}
function setDeep(obj, key, value) {
const parts = key.split('.');
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
cur = cur[parts[i]];
}
if (cur[parts[parts.length - 1]] === undefined) cur[parts[parts.length - 1]] = value;
}
function sortDeep(obj) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj;
return Object.keys(obj).sort().reduce((acc, k) => {
acc[k] = sortDeep(obj[k]);
return acc;
}, {});
}
const en = flatten(JSON.parse(readFileSync(EN, 'utf8')));
const overlay = JSON.parse(readFileSync(OVERLAY, 'utf8'));
const overlayFlat = flatten(overlay);
// t('key', 'fallback') / t('key', "fallback") / t('key', `fallback`) - the
// fallback may span lines; keep it simple: single-quoted or double-quoted,
// non-nested strings.
const CALL_RE = /\bt\(\s*'([^']+)'\s*,\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)")/g;
const KEY_ONLY_RE = /\bt\(\s*'([^']+)'/g;
const added = [];
const missingNoFallback = [];
for (const file of walk(SRC)) {
const text = readFileSync(file, 'utf8');
const withFallback = new Map();
for (const m of text.matchAll(CALL_RE)) {
const value = (m[2] ?? m[3] ?? '').replace(/\\(['"])/g, '$1');
if (value) withFallback.set(m[1], value);
}
for (const m of text.matchAll(KEY_ONLY_RE)) {
const key = m[1];
if (en.has(key) || overlayFlat.has(key)) continue;
const fallback = withFallback.get(key);
if (!fallback) {
missingNoFallback.push(`${key} (${relative(ROOT, file)})`);
continue;
}
if (!added.some((a) => a.key === key)) added.push({ key, fallback, file: relative(ROOT, file) });
}
}
if (added.length === 0 && missingNoFallback.length === 0) {
console.log('All t() keys are covered.');
process.exit(0);
}
for (const a of added) console.log(`${CHECK_ONLY ? 'missing' : 'added'}: ${a.key} = ${JSON.stringify(a.fallback)} (${a.file})`);
for (const m of missingNoFallback) console.log(`missing (no inline fallback, add by hand): ${m}`);
if (CHECK_ONLY) process.exit(1);
for (const a of added) setDeep(overlay, a.key, a.fallback);
writeFileSync(OVERLAY, JSON.stringify(sortDeep(overlay), null, 2) + '\n');
console.log(`Wrote ${added.length} key(s) to locales/rn/en.json`);
+65 -18
View File
@@ -1,43 +1,90 @@
// Copy locale JSON files from the parent webmail repo into ./locales/.
// Run from the RN repo root: `node scripts/sync-locales.mjs`.
// Vendor the webmail locale catalogs into ./locales/<lang>/common.json.
// Run from the RN repo root: `node scripts/sync-locales.mjs [--check]`.
//
// The webmail catalog is the source of truth and is copied verbatim. Keys the
// native app needs that the webmail does not have live in ./locales/rn/<lang>.json
// and are merged on top at runtime (src/i18n/index.ts) - they are never written
// into the vendored files, so a re-sync cannot lose them. Keys that later land
// in the webmail catalog are reported so the overlay can be trimmed.
//
// In environments where the parent repo isn't checked out (e.g. CI clone of
// the standalone RN repo), this is a no-op — vendored files stay as-is.
import { readdir, mkdir, copyFile, stat } from 'node:fs/promises';
import { readdir, mkdir, copyFile, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const RN_LOCALES = join(__dirname, '..', 'locales');
const RN_OVERLAYS = join(RN_LOCALES, 'rn');
const WEBMAIL_LOCALES = join(__dirname, '..', '..', '..', 'locales');
const CHECK_ONLY = process.argv.includes('--check');
if (!existsSync(WEBMAIL_LOCALES)) {
console.log(`No webmail locales at ${WEBMAIL_LOCALES} — skipping (vendored files will be used).`);
process.exit(0);
}
async function copyDir(src, dest) {
await mkdir(dest, { recursive: true });
for (const entry of await readdir(src, { withFileTypes: true })) {
const s = join(src, entry.name);
const d = join(dest, entry.name);
if (entry.isDirectory()) {
await copyDir(s, d);
} else if (entry.isFile() && entry.name.endsWith('.json')) {
await copyFile(s, d);
}
function flatten(obj, prefix = '', out = new Map()) {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out);
else out.set(key, v);
}
return out;
}
async function readJson(path) {
return JSON.parse(await readFile(path, 'utf8'));
}
const langs = (await readdir(WEBMAIL_LOCALES, { withFileTypes: true }))
.filter((e) => e.isDirectory())
.map((e) => e.name);
.map((e) => e.name)
.sort();
let copied = 0;
let problems = 0;
for (const lang of langs) {
const src = join(WEBMAIL_LOCALES, lang);
const dest = join(RN_LOCALES, lang);
await copyDir(src, dest);
copied++;
const src = join(WEBMAIL_LOCALES, lang, 'common.json');
if (!existsSync(src)) continue;
const destDir = join(RN_LOCALES, lang);
const dest = join(destDir, 'common.json');
if (CHECK_ONLY) {
if (!existsSync(dest)) {
console.log(`missing: locales/${lang}/common.json`);
problems++;
continue;
}
const [a, b] = await Promise.all([readFile(src, 'utf8'), readFile(dest, 'utf8')]);
if (a !== b) {
console.log(`stale: locales/${lang}/common.json differs from the webmail`);
problems++;
}
} else {
await mkdir(destDir, { recursive: true });
await copyFile(src, dest);
copied++;
}
// Report overlay keys the webmail now ships (safe to delete from the overlay)
// so the RN-only set stays minimal.
const overlayPath = join(RN_OVERLAYS, `${lang}.json`);
if (existsSync(overlayPath)) {
const webKeys = flatten(await readJson(src));
const overlayKeys = flatten(await readJson(overlayPath));
const shadowed = [...overlayKeys.keys()].filter((k) => webKeys.has(k));
if (shadowed.length > 0) {
console.log(
`locales/rn/${lang}.json: ${shadowed.length} key(s) now exist in the webmail catalog and shadow it:\n ${shadowed.join('\n ')}`,
);
}
}
}
if (CHECK_ONLY) {
console.log(problems === 0 ? 'Vendored locales are up to date.' : `${problems} locale(s) out of date.`);
process.exit(problems === 0 ? 0 : 1);
}
console.log(`Synced ${copied} locale(s) from ${WEBMAIL_LOCALES} → ${RN_LOCALES}`);
+78 -1
View File
@@ -7,6 +7,23 @@ vi.mock('expo-file-system', () => ({
},
}));
// The streaming upload path is mocked as a controllable task so the tests
// can drive progress, completion and cancellation.
const uploadTask = {
uploadAsync: vi.fn(),
cancelAsync: vi.fn(async () => undefined),
};
const createUploadTask = vi.fn((..._args: unknown[]) => uploadTask);
vi.mock('expo-file-system/legacy', () => ({
createUploadTask: (...args: unknown[]) => createUploadTask(...args),
FileSystemUploadType: { BINARY_CONTENT: 0, MULTIPART: 1 },
}));
vi.mock('../../lib/client-cert', () => ({
getClientCertAlias: vi.fn(async () => null),
secureFetch: vi.fn(),
}));
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
@@ -18,7 +35,7 @@ vi.mock('../jmap-client', () => ({
},
}));
import { getDownloadUrl } from '../blob';
import { getDownloadUrl, uploadBlob } from '../blob';
beforeEach(() => {
vi.clearAllMocks();
@@ -49,3 +66,63 @@ describe('blob operations', () => {
});
});
});
describe('uploadBlob', () => {
it('streams the file through a native upload task with auth headers and reports progress', async () => {
let progressCb: ((d: { totalBytesSent: number; totalBytesExpectedToSend: number }) => void) | undefined;
createUploadTask.mockImplementationOnce((...args: unknown[]) => {
progressCb = args[3] as typeof progressCb;
return uploadTask;
});
uploadTask.uploadAsync.mockImplementationOnce(async () => {
progressCb?.({ totalBytesSent: 50, totalBytesExpectedToSend: 100 });
return { status: 201, body: JSON.stringify({ blobId: 'blob-1', size: 100, type: 'image/png' }) };
});
const onProgress = vi.fn();
const result = await uploadBlob('file:///tmp/a.png', 'image/png', { onProgress });
expect(result).toEqual({ blobId: 'blob-1', size: 100, type: 'image/png' });
expect(onProgress).toHaveBeenCalledWith(50, 100);
const [url, uri, options] = createUploadTask.mock.calls[0] as unknown as [string, string, { headers: Record<string, string>; httpMethod: string }];
expect(url).toBe('https://mail.example.com/upload/acc-1/');
expect(uri).toBe('file:///tmp/a.png');
expect(options.httpMethod).toBe('POST');
expect(options.headers).toEqual({ 'Content-Type': 'image/png', Authorization: 'Basic dXNlcjpwYXNz' });
});
it('accepts the per-account nested response shape', async () => {
uploadTask.uploadAsync.mockResolvedValueOnce({
status: 200,
body: JSON.stringify({ 'acc-1': { blobId: 'blob-2', size: 7, type: 'text/plain' } }),
});
const result = await uploadBlob('file:///tmp/a.txt', 'text/plain');
expect(result.blobId).toBe('blob-2');
});
it('surfaces HTTP failures with the status code', async () => {
uploadTask.uploadAsync.mockResolvedValueOnce({ status: 413, body: 'too large' });
await expect(uploadBlob('file:///tmp/a.bin', 'application/octet-stream')).rejects.toThrow(/413/);
});
it('cancels the native task when the signal aborts', async () => {
const controller = new AbortController();
uploadTask.uploadAsync.mockImplementationOnce(async () => {
controller.abort();
return null;
});
await expect(
uploadBlob('file:///tmp/a.bin', 'application/octet-stream', { signal: controller.signal }),
).rejects.toMatchObject({ name: 'AbortError' });
expect(uploadTask.cancelAsync).toHaveBeenCalled();
});
it('rejects immediately when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
await expect(
uploadBlob('file:///tmp/a.bin', 'application/octet-stream', { signal: controller.signal }),
).rejects.toMatchObject({ name: 'AbortError' });
expect(createUploadTask).not.toHaveBeenCalled();
});
});
+189 -9
View File
@@ -16,8 +16,21 @@ import {
createEvent,
updateEvent,
deleteEvents,
toLocalDateTime,
} from '../calendar';
describe('toLocalDateTime', () => {
it('renders an instant as wall-clock in the given zone', () => {
expect(toLocalDateTime('2026-03-01T00:30:00Z', 'Europe/Berlin')).toBe('2026-03-01T01:30:00');
expect(toLocalDateTime('2026-03-01T00:30:00Z', 'America/New_York')).toBe('2026-02-28T19:30:00');
expect(toLocalDateTime('2026-07-01T23:15:00.000Z', 'UTC')).toBe('2026-07-01T23:15:00');
});
it('passes LocalDateTime strings through', () => {
expect(toLocalDateTime('2026-03-01T00:00:00', 'UTC')).toMatch(/^2026-0[23]-\d{2}T\d{2}:00:00$/);
});
});
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
@@ -42,26 +55,106 @@ describe('calendar operations', () => {
});
describe('queryEvents', () => {
it('should send a CalendarEvent/query without server-side filter', async () => {
// Stalwart rejects inCalendars/after/before, so we fetch unfiltered and
// filter client-side. Asserting on the absence of `filter` keeps that
// contract pinned.
it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => {
// Stalwart rejects the draft's plural `inCalendars` filter; calendars
// are restricted via singular `inCalendar` conditions.
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
});
const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
const result = await queryEvents(['cal-1'], '', '');
expect(result).toEqual(['ev1', 'ev2']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[0]).toBe('CalendarEvent/query');
expect(call[1].filter).toBeUndefined();
// No bounds: only the calendar restriction is sent.
expect(call[1].filter).toEqual({ inCalendar: 'cal-1' });
expect(call[1].accountId).toBe('acc-1');
expect(call[1].position).toBe(0);
});
it('should send the date window as LocalDateTime after/before combined with the calendar filter', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter.operator).toBe('AND');
expect(call[1].filter.conditions[0]).toEqual({ inCalendar: 'cal-1' });
const range = call[1].filter.conditions[1];
expect(range.after).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
expect(range.before).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
expect(call[1].timeZone).toBeTruthy();
});
it('should send only the range when no calendars are given', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
await queryEvents([], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter.operator).toBeUndefined();
expect(Object.keys(call[1].filter).sort()).toEqual(['after', 'before']);
});
it('should page through a result set larger than one page', async () => {
// Regression: a full page used to be returned as the complete answer, so
// an account with more events than the page size silently lost every
// event past it - the calendar rendered empty.
const first = Array.from({ length: 1000 }, (_, i) => `a${i}`);
mockRequest
.mockResolvedValueOnce({
methodResponses: [['CalendarEvent/query', { ids: first }, '0']],
})
.mockResolvedValueOnce({
methodResponses: [['CalendarEvent/query', { ids: ['tail-1', 'tail-2'] }, '0']],
});
const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z');
expect(result).toHaveLength(1002);
expect(result[1001]).toBe('tail-2');
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(mockRequest.mock.calls[0][0][0][1].position).toBe(0);
expect(mockRequest.mock.calls[1][0][0][1].position).toBe(1000);
});
it('should OR multiple calendars and respect the target account', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
await queryEvents(['cal-1', 'cal-2'], '', '', 'acc-shared');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
operator: 'OR',
conditions: [{ inCalendar: 'cal-1' }, { inCalendar: 'cal-2' }],
});
expect(call[1].accountId).toBe('acc-shared');
});
it('should omit the filter when no calendars are given and window is empty', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
await queryEvents([], '', '');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toBeUndefined();
});
});
describe('getEvents', () => {
it('should fetch events by id with expected properties', async () => {
it('should fetch events by id with singular JSCalendar 2.0 recurrence properties', async () => {
// Stalwart (calcard) names the property `recurrenceRule` (singular,
// single object) — requesting the RFC 8984 plural form returns no
// recurrence data and repeating events vanish from the grid (#13).
const events = [{ id: 'ev1', title: 'Meeting', start: '2026-03-29T10:00:00' }];
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
@@ -73,7 +166,48 @@ describe('calendar operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].properties).toContain('title');
expect(call[1].properties).toContain('start');
expect(call[1].properties).toContain('recurrenceRules');
expect(call[1].properties).toContain('recurrenceRule');
expect(call[1].properties).toContain('excludedRecurrenceRule');
expect(call[1].properties).not.toContain('recurrenceRules');
expect(call[1].properties).not.toContain('excludedRecurrenceRules');
});
it('should normalize a singular recurrenceRule object to the plural array form', async () => {
const events = [{
id: 'ev1',
title: 'Every 5 weeks',
start: '2026-03-02T10:00:00',
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
excludedRecurrenceRule: { frequency: 'weekly', interval: 10 },
}];
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
});
const [event] = await getEvents(['ev1']);
expect(event.recurrenceRules).toEqual([
{ '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
]);
expect(event.excludedRecurrenceRules).toEqual([
{ frequency: 'weekly', interval: 10 },
]);
expect(event).not.toHaveProperty('recurrenceRule');
expect(event).not.toHaveProperty('excludedRecurrenceRule');
});
it('should keep an array-valued recurrenceRule as-is (JMAP-created events)', async () => {
const events = [{
id: 'ev1',
title: 'Weekly',
start: '2026-03-02T10:00:00',
recurrenceRule: [{ frequency: 'weekly', interval: 2 }],
}];
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
});
const [event] = await getEvents(['ev1']);
expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]);
});
});
@@ -89,10 +223,39 @@ describe('calendar operations', () => {
'cal-1',
);
expect(result).toEqual(created);
// The /set response echoes only server-set properties; the return value
// merges them over the submitted payload.
expect(result).toEqual({
id: 'ev-new',
title: 'Lunch',
start: '2026-03-30T12:00:00',
duration: 'PT1H',
calendarIds: { 'cal-1': true },
});
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].create['new-event'].calendarIds).toEqual({ 'cal-1': true });
});
it('should send recurrence as a singular cleaned object', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', { created: { 'new-event': { id: 'ev-new' } } }, '0']],
});
const result = await createEvent(
{
title: 'Series',
start: '2026-03-30T12:00:00',
recurrenceRules: [{ frequency: 'weekly', interval: 5, until: null as unknown as string }],
},
'cal-1',
);
const sent = mockRequest.mock.calls[0][0][0][1].create['new-event'];
expect(sent.recurrenceRule).toEqual({ frequency: 'weekly', interval: 5 });
expect(sent).not.toHaveProperty('recurrenceRules');
// The returned event exposes the internal plural form again.
expect(result.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 5 }]);
});
});
describe('updateEvent', () => {
@@ -106,6 +269,23 @@ describe('calendar operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ ev1: { title: 'Updated Meeting' } });
});
it('should convert recurrenceRules updates to the singular property', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/set', { updated: {} }, '0']],
});
await updateEvent('ev1', { recurrenceRules: [{ frequency: 'monthly' }] });
expect(mockRequest.mock.calls[0][0][0][1].update).toEqual({
ev1: { recurrenceRule: { frequency: 'monthly' } },
});
// Empty array means "remove recurrence" — sent as an explicit null.
await updateEvent('ev1', { recurrenceRules: [] });
expect(mockRequest.mock.calls[1][0][0][1].update).toEqual({
ev1: { recurrenceRule: null },
});
});
});
describe('deleteEvents', () => {
+280 -16
View File
@@ -1,8 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const session = {
primaryAccounts: {} as Record<string, string>,
accounts: {} as Record<string, { name: string; isPersonal: boolean; accountCapabilities?: Record<string, unknown> }>,
};
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
username: 'me@example.com',
get currentSession() { return session; },
request: vi.fn(),
getMaxObjectsInGet: () => 500,
},
@@ -11,22 +18,50 @@ vi.mock('../jmap-client', () => ({
import { jmapClient } from '../jmap-client';
import {
getAddressBooks,
getAllAddressBooks,
getContactsAccountId,
getContactCapableAccountIds,
queryContacts,
getContacts,
getAllContacts,
createContact,
updateContact,
deleteContacts,
createAddressBook,
updateAddressBook,
setDefaultAddressBook,
deleteAddressBook,
} from '../contacts';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
session.primaryAccounts = {};
session.accounts = {};
});
describe('contacts operations', () => {
describe('account resolution', () => {
it('prefers the contacts primary account over the mail account', () => {
expect(getContactsAccountId()).toBe('acc-1');
session.primaryAccounts = { 'urn:ietf:params:jmap:contacts': 'acc-contacts' };
expect(getContactsAccountId()).toBe('acc-contacts');
});
it('lists contacts-capable and non-personal accounts after the primary', () => {
session.accounts = {
'acc-1': { name: 'me', isPersonal: true },
'acc-team': { name: 'Team', isPersonal: false },
'acc-cal': { name: 'Cal', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
'acc-ct': { name: 'Ct', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:contacts': {} } },
};
expect(getContactCapableAccountIds()).toEqual(['acc-1', 'acc-team', 'acc-ct']);
});
});
describe('getAddressBooks', () => {
it('should fetch address books', async () => {
it('should fetch address books with an explicit property list', async () => {
const books = [{ id: 'ab-1', name: 'Personal' }];
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/get', { list: books }, '0']],
@@ -34,21 +69,64 @@ describe('contacts operations', () => {
const result = await getAddressBooks();
expect(result).toEqual(books);
expect(mockRequest).toHaveBeenCalledWith(
[['AddressBook/get', { accountId: 'acc-1' }, '0']],
expect.arrayContaining(['urn:ietf:params:jmap:contacts']),
);
const call = mockRequest.mock.calls[0][0][0];
expect(call[0]).toBe('AddressBook/get');
expect(call[1].accountId).toBe('acc-1');
expect(call[1].properties).toEqual(expect.arrayContaining(['shareWith', 'myRights', 'isDefault']));
expect(mockRequest.mock.calls[0][1]).toEqual(expect.arrayContaining(['urn:ietf:params:jmap:contacts']));
});
it('throws on a method-level error', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['error', { type: 'serverFail', description: 'boom' }, '0']],
});
await expect(getAddressBooks()).rejects.toThrow('boom');
});
});
describe('getAllAddressBooks', () => {
it('namespaces books from shared accounts and tags them', async () => {
session.accounts = {
'acc-1': { name: 'me', isPersonal: true },
'acc-team': { name: 'Team', isPersonal: false },
};
mockRequest
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Mine' }] }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Shared' }] }, '0']] });
const books = await getAllAddressBooks();
expect(books).toHaveLength(2);
expect(books[0]).toMatchObject({ id: 'default', originalId: 'default', isShared: false });
expect(books[1]).toMatchObject({
id: 'acc-team:default', originalId: 'default', accountId: 'acc-team', accountName: 'Team', isShared: true,
});
});
it('keeps own books when a shared account fails', async () => {
session.accounts = {
'acc-1': { name: 'me', isPersonal: true },
'acc-team': { name: 'Team', isPersonal: false },
};
mockRequest
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Mine' }] }, '0']] })
.mockRejectedValueOnce(new Error('forbidden'));
const books = await getAllAddressBooks();
expect(books.map((b) => b.id)).toEqual(['default']);
});
});
describe('queryContacts', () => {
it('should query contacts with text filter', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/query', { ids: ['c1', 'c2'] }, '0']],
methodResponses: [['ContactCard/query', { ids: ['c1', 'c2'], total: 2 }, '0']],
});
const result = await queryContacts({ text: 'john' });
expect(result).toEqual(['c1', 'c2']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'john' });
expect(call[1].position).toBe(0);
});
it('should omit filter when none provided', async () => {
@@ -60,6 +138,28 @@ describe('contacts operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toBeUndefined();
});
it('walks position until every id is collected', async () => {
mockRequest
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['a', 'b'], total: 5 }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c', 'd'], total: 5 }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['e'], total: 5 }, '0']] });
const result = await queryContacts(undefined, 2);
expect(result).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(mockRequest).toHaveBeenCalledTimes(3);
expect(mockRequest.mock.calls[1][0][0][1].position).toBe(2);
expect(mockRequest.mock.calls[2][0][0][1].position).toBe(4);
});
it('stops when the server returns a short page without a total', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/query', { ids: ['a'] }, '0']],
});
const result = await queryContacts(undefined, 2);
expect(result).toEqual(['a']);
expect(mockRequest).toHaveBeenCalledTimes(1);
});
});
describe('getContacts', () => {
@@ -74,21 +174,85 @@ describe('contacts operations', () => {
});
});
describe('createContact', () => {
it('should create a contact in the specified address book', async () => {
const created = { id: 'c-new', addressBookIds: { 'ab-1': true } };
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { created: { 'new-contact': created } }, '0']],
describe('getAllContacts', () => {
it('namespaces cards and their book ids for shared accounts', async () => {
session.accounts = {
'acc-1': { name: 'me', isPersonal: true },
'acc-team': { name: 'Team', isPersonal: false },
};
mockRequest
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c1'], total: 1 }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [{ id: 'c1', addressBookIds: { default: true } }] }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c9'], total: 1 }, '0']] })
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [{ id: 'c9', addressBookIds: { default: true } }] }, '0']] });
const cards = await getAllContacts();
expect(cards[0]).toEqual({ id: 'c1', addressBookIds: { default: true } });
expect(cards[1]).toMatchObject({
id: 'acc-team:c9',
originalId: 'c9',
addressBookIds: { 'acc-team:default': true },
accountId: 'acc-team',
isShared: true,
});
});
});
describe('createContact', () => {
it('assigns a urn:uuid UID, then re-fetches the created card', async () => {
const full = {
id: 'c-new',
uid: 'urn:uuid:whatever',
addressBookIds: { 'ab-1': true },
name: { components: [{ kind: 'given', value: 'Jane' }] },
};
mockRequest
.mockResolvedValueOnce({
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'c-new' } } }, '0']],
})
.mockResolvedValueOnce({
methodResponses: [['ContactCard/get', { list: [full] }, '0']],
});
const result = await createContact(
{ name: { components: [{ kind: 'given', value: 'Jane' }] } },
'ab-1',
);
expect(result).toEqual(created);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].create['new-contact'].addressBookIds).toEqual({ 'ab-1': true });
expect(result).toEqual(full);
const create = mockRequest.mock.calls[0][0][0][1].create['new-contact'];
expect(create.addressBookIds).toEqual({ 'ab-1': true });
expect(create.uid).toMatch(/^urn:uuid:[0-9a-f-]{36}$/);
expect(mockRequest.mock.calls[1][0][0][0]).toBe('ContactCard/get');
expect(mockRequest.mock.calls[1][0][0][1].ids).toEqual(['c-new']);
});
it('keeps an existing uid and strips client-only fields', async () => {
mockRequest
.mockResolvedValueOnce({
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'c-new' } } }, '0']],
})
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [] }, '0']] });
const result = await createContact(
{ uid: 'urn:uuid:keep', isShared: true, accountName: 'x', name: { full: 'A' } },
'ab-1',
);
const create = mockRequest.mock.calls[0][0][0][1].create['new-contact'];
expect(create.uid).toBe('urn:uuid:keep');
expect(create.isShared).toBeUndefined();
expect(create.accountName).toBeUndefined();
// Falls back to a client-side merge when the re-fetch comes back empty.
expect(result).toMatchObject({ id: 'c-new', uid: 'urn:uuid:keep', name: { full: 'A' }, addressBookIds: { 'ab-1': true } });
});
it('throws the server description when the card is rejected', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', {
notCreated: { 'new-contact': { type: 'invalidProperties', description: 'bad date', properties: ['anniversaries'] } },
}, '0']],
});
await expect(createContact({ name: { full: 'A' } }, 'ab-1')).rejects.toThrow('bad date (anniversaries)');
});
});
@@ -103,18 +267,118 @@ describe('contacts operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ c1: { kind: 'individual' } });
});
it('throws when the server refuses the update', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { notUpdated: { c1: { type: 'forbidden', description: 'read-only book' } } }, '0']],
});
await expect(updateContact('c1', { kind: 'org' })).rejects.toThrow('read-only book');
});
it('routes to the given account', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['ContactCard/set', { updated: {} }, '0']] });
await updateContact('c1', { kind: 'org' }, 'acc-team');
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-team');
});
});
describe('deleteContacts', () => {
it('should destroy contacts by ids', async () => {
it('should destroy contacts by ids and return the destroyed ones', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { destroyed: ['c1', 'c2'] }, '0']],
});
await deleteContacts(['c1', 'c2']);
const destroyed = await deleteContacts(['c1', 'c2']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].destroy).toEqual(['c1', 'c2']);
expect(destroyed).toEqual(['c1', 'c2']);
});
it('returns only the destroyed subset on partial failure', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { destroyed: ['c1'], notDestroyed: { c2: { type: 'forbidden' } } }, '0']],
});
expect(await deleteContacts(['c1', 'c2'])).toEqual(['c1']);
});
it('throws when nothing was destroyed', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/set', { notDestroyed: { c1: { type: 'forbidden', description: 'nope' } } }, '0']],
});
await expect(deleteContacts(['c1'])).rejects.toThrow('nope');
});
});
describe('createAddressBook', () => {
it('creates a book and returns the created entity', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { created: { 'new-book': { id: 'ab-9' } } }, '0']],
});
const book = await createAddressBook('Work');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].create).toEqual({ 'new-book': { name: 'Work' } });
expect(book).toEqual({ id: 'ab-9', name: 'Work' });
});
it('throws when the server refuses to create', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { notCreated: { 'new-book': { description: 'nope' } } }, '0']],
});
await expect(createAddressBook('X')).rejects.toThrow('nope');
});
});
describe('updateAddressBook', () => {
it('forwards only settable properties and never isDefault', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
});
await updateAddressBook('ab-1', { name: 'Renamed', isDefault: true, id: 'should-be-ignored' } as never);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } });
});
});
describe('setDefaultAddressBook', () => {
it('uses onSuccessSetIsDefault', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { updated: {} }, '0']],
});
await setDefaultAddressBook('ab-2');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1]).toEqual({ accountId: 'acc-1', onSuccessSetIsDefault: 'ab-2' });
});
it('throws on a method error', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['error', { type: 'invalidArguments', description: 'no such book' }, '0']],
});
await expect(setDefaultAddressBook('ab-x')).rejects.toThrow('no such book');
});
});
describe('deleteAddressBook', () => {
it('destroys the book by id', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { destroyed: ['ab-1'] }, '0']],
});
await deleteAddressBook('ab-1');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].destroy).toEqual(['ab-1']);
});
it('throws when destroy fails', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { notDestroyed: { 'ab-1': { description: 'in use' } } }, '0']],
});
await expect(deleteAddressBook('ab-1')).rejects.toThrow('in use');
});
});
});
+107 -5
View File
@@ -5,12 +5,18 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getAccountName: vi.fn(() => 'me@example.com'),
getSharedMailAccounts: vi.fn(() => []),
getMaxCallsInRequest: vi.fn(() => 16),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
},
}));
import { jmapClient } from '../jmap-client';
import {
getMailboxes,
getSharedMailboxes,
queryEmails,
getEmails,
getFullEmail,
@@ -41,13 +47,107 @@ describe('email operations', () => {
const result = await getMailboxes();
expect(result).toEqual(mailboxes);
// Own folders keep their raw ids and pick up ownership metadata.
expect(result).toEqual(mailboxes.map((m) => ({
...m,
accountId: 'acc-1',
accountName: 'me@example.com',
isShared: false,
})));
expect(mockRequest).toHaveBeenCalledWith(
[['Mailbox/get', { accountId: 'acc-1' }, '0']],
);
});
});
describe('getSharedMailboxes', () => {
const mockShared = jmapClient.getSharedMailAccounts as ReturnType<typeof vi.fn>;
it('returns nothing when the session has no shared accounts', async () => {
mockShared.mockReturnValue([]);
expect(await getSharedMailboxes()).toEqual([]);
expect(mockRequest).not.toHaveBeenCalled();
});
it('prefixes ids and parent links per owning account', async () => {
mockShared.mockReturnValue([{ id: 'grp-1', name: 'Support' }]);
mockRequest.mockResolvedValue({
methodResponses: [['Mailbox/get', {
list: [
{ id: 'mb-1', name: 'Inbox', role: 'inbox', totalEmails: 4, unreadEmails: 2 },
{ id: 'mb-2', name: 'Escalations', parentId: 'mb-1', totalEmails: 1, unreadEmails: 0 },
],
}, '0']],
});
const result = await getSharedMailboxes();
expect(mockRequest).toHaveBeenCalledWith(
[['Mailbox/get', { accountId: 'grp-1' }, '0']],
);
expect(result).toEqual([
{
id: 'grp-1:mb-1',
originalId: 'mb-1',
name: 'Inbox',
role: 'inbox',
parentId: undefined,
totalEmails: 4,
unreadEmails: 2,
accountId: 'grp-1',
accountName: 'Support',
isShared: true,
},
{
id: 'grp-1:mb-2',
originalId: 'mb-2',
name: 'Escalations',
parentId: 'grp-1:mb-1',
totalEmails: 1,
unreadEmails: 0,
accountId: 'grp-1',
accountName: 'Support',
isShared: true,
},
]);
});
it('maps each response back to its account and skips the ones that errored', async () => {
mockShared.mockReturnValue([
{ id: 'grp-1', name: 'Support' },
{ id: 'grp-2', name: 'Sales' },
]);
mockRequest.mockResolvedValue({
methodResponses: [
['error', { type: 'accountNotFound' }, '0'],
['Mailbox/get', { list: [{ id: 'mb-9', name: 'Inbox', role: 'inbox' }] }, '1'],
],
});
const result = await getSharedMailboxes();
expect(result.map((m) => m.id)).toEqual(['grp-2:mb-9']);
expect(result[0].accountName).toBe('Sales');
});
it('chunks requests to maxCallsInRequest', async () => {
mockShared.mockReturnValue([
{ id: 'grp-1', name: 'Support' },
{ id: 'grp-2', name: 'Sales' },
{ id: 'grp-3', name: 'Billing' },
]);
(jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>).mockReturnValue(2);
mockRequest.mockResolvedValue({ methodResponses: [] });
await getSharedMailboxes();
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(mockRequest.mock.calls[0][0]).toHaveLength(2);
expect(mockRequest.mock.calls[1][0]).toHaveLength(1);
});
});
describe('queryEmails', () => {
it('should query emails with default options', async () => {
mockRequest.mockResolvedValue({
@@ -296,16 +396,18 @@ describe('email operations', () => {
to: [{ email: 'you@example.com' }],
subject: 'Re: Hello',
textBody: 'replying',
inReplyTo: '<msg-1@example.com>',
references: '<msg-0@example.com> <msg-1@example.com>',
inReplyTo: ['<msg-1@example.com>'],
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
},
'identity-1',
'sent-mb',
);
const emailCreate = mockRequest.mock.calls[0][0][0][1].create.draft;
expect(emailCreate['header:In-Reply-To:asText']).toBe('<msg-1@example.com>');
expect(emailCreate['header:References:asText']).toBe('<msg-0@example.com> <msg-1@example.com>');
// RFC 8621 §4.1.2.3: JMAP arrays of bare msg-ids, brackets stripped.
expect(emailCreate.inReplyTo).toEqual(['msg-1@example.com']);
expect(emailCreate.references).toEqual(['msg-0@example.com', 'msg-1@example.com']);
expect(emailCreate['header:In-Reply-To:asText']).toBeUndefined();
});
});
});
+359
View File
@@ -0,0 +1,359 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
hasCapability: vi.fn(),
hasAccountCapability: vi.fn(() => false),
getMaxSizeUpload: vi.fn(() => 0),
currentSession: null as unknown,
},
}));
vi.mock('../blob', () => ({
getDownloadUrl: vi.fn(
(blobId: string, _name?: string, _type?: string, accountId?: string) =>
`https://dl/${accountId ?? 'own'}/${blobId}`,
),
uploadBlob: vi.fn(),
uploadBytes: vi.fn(),
}));
import { jmapClient } from '../jmap-client';
import { CAPABILITIES } from '../types';
import {
accountSupportsFiles,
copyFileNode,
createFolder,
deleteFileNodes,
getAllFileNodes,
getAllFileNodesAcrossAccounts,
getFileNodeDownloadUrl,
getPrincipals,
isCrossAccountId,
isFolder,
moveFileNode,
setFileNodeShare,
supportsSharing,
} from '../files';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
function setSession(session: unknown) {
(jmapClient as { currentSession: unknown }).currentSession = session;
}
beforeEach(() => {
vi.clearAllMocks();
mockHasCapability.mockImplementation(
(urn: string) =>
urn === CAPABILITIES.FILES ||
urn === CAPABILITIES.PRINCIPALS ||
urn === CAPABILITIES.PRINCIPALS_OWNER,
);
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
},
});
});
describe('isFolder', () => {
it('treats blob-less nodes as folders and anything with a blob as a file', () => {
// #379: a blob-marked node — even the legacy type:'d' dir markers — is a
// 0-byte file that nothing can be parented under.
expect(isFolder({ blobId: null })).toBe(true);
expect(isFolder({ blobId: undefined })).toBe(true);
expect(isFolder({ blobId: 'blob-1' })).toBe(false);
});
});
describe('createFolder', () => {
it('creates a real container node: no blobId/type/size, parentId set', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
});
await createFolder('Docs', 'parent-1');
const [method, args] = mockRequest.mock.calls[0][0][0];
expect(method).toBe('FileNode/set');
expect(args.create['new-dir']).toEqual({ name: 'Docs', parentId: 'parent-1' });
});
it('omits parentId at the root', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
});
await createFolder('Docs', null);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.create['new-dir']).toEqual({ name: 'Docs' });
});
});
describe('deleteFileNodes', () => {
it('lets the server cascade into folder children', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { destroyed: ['f1'] }, '0']],
});
await deleteFileNodes(['f1']);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.destroy).toEqual(['f1']);
expect(args.onDestroyRemoveChildren).toBe(true);
});
it('throws when the server refuses a destroy', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['FileNode/set', { notDestroyed: { f1: { description: 'forbidden' } } }, '0'],
],
});
await expect(deleteFileNodes(['f1'])).rejects.toThrow('forbidden');
});
});
describe('setFileNodeShare', () => {
it('patches shareWith/{principalId} and requests the principals:owner capability', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
const rights = {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
};
await setFileNodeShare('node-1', 'principal-2', rights);
const [calls, using] = mockRequest.mock.calls[0];
expect(calls[0][1].update['node-1']).toEqual({ 'shareWith/principal-2': rights });
expect(using).toContain(CAPABILITIES.PRINCIPALS_OWNER);
});
it('sends null rights to revoke access', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
await setFileNodeShare('node-1', 'principal-2', null);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.update['node-1']).toEqual({ 'shareWith/principal-2': null });
});
it('throws when the server rejects or does not confirm the update', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['FileNode/set', { notUpdated: { 'node-1': { description: 'no mayShare right' } } }, '0'],
],
});
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow('no mayShare right');
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', {}, '0']] });
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow(
'Server did not confirm the share update',
);
});
});
describe('getAllFileNodesAcrossAccounts', () => {
it('namespaces ids of nodes from other principals and tags them isShared', async () => {
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
'acc-2': { name: 'Team', isPersonal: false },
},
});
mockRequest
.mockResolvedValueOnce({
methodResponses: [
['FileNode/get', { list: [{ id: 'own-1', name: 'mine.txt', parentId: null, blobId: 'b1' }] }, '0'],
],
})
.mockResolvedValueOnce({
methodResponses: [
['FileNode/get', {
list: [
{ id: 's1', name: 'Shared Folder', parentId: null, blobId: null },
{ id: 's2', name: 'inside.txt', parentId: 's1', blobId: 'b2' },
],
}, '0'],
],
});
const nodes = await getAllFileNodesAcrossAccounts();
expect(nodes).toHaveLength(3);
const own = nodes.find((n) => n.id === 'own-1')!;
expect(own.isShared).toBe(false);
expect(isCrossAccountId(own.id)).toBe(false);
const folder = nodes.find((n) => n.name === 'Shared Folder')!;
expect(folder.id).toBe('acc-2:s1');
expect(folder.isShared).toBe(true);
expect(folder.accountId).toBe('acc-2');
expect(folder.accountName).toBe('Team');
// parentId is namespaced the same way so child lookups keep working.
const child = nodes.find((n) => n.name === 'inside.txt')!;
expect(child.parentId).toBe('acc-2:s1');
});
it('skips personal accounts without the filenode capability', async () => {
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
'acc-3': { name: 'other personal', isPersonal: true },
},
});
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/get', { list: [] }, '0']],
});
await getAllFileNodesAcrossAccounts();
expect(mockRequest).toHaveBeenCalledTimes(1);
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-1');
});
});
describe('getFileNodeDownloadUrl', () => {
it('routes shared blobs to the owning account', () => {
const url = getFileNodeDownloadUrl({
id: 'acc-2:s2', name: 'inside.txt', type: 'text/plain',
blobId: 'b2', accountId: 'acc-2',
});
expect(url).toBe('https://dl/acc-2/b2');
});
});
describe('getPrincipals', () => {
it('queries then gets via back-reference', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Principal/query', { ids: ['p1'] }, '0'],
['Principal/get', { list: [{ id: 'p1', name: 'other', type: 'individual' }] }, '1'],
],
});
const principals = await getPrincipals();
expect(principals).toEqual([{ id: 'p1', name: 'other', type: 'individual' }]);
const calls = mockRequest.mock.calls[0][0];
expect(calls[0][0]).toBe('Principal/query');
expect(calls[1][0]).toBe('Principal/get');
expect(calls[1][1]['#ids']).toEqual({ resultOf: '0', name: 'Principal/query', path: '/ids' });
});
it('returns empty when the server lacks the principals capability', async () => {
mockHasCapability.mockReturnValue(false);
expect(await getPrincipals()).toEqual([]);
expect(mockRequest).not.toHaveBeenCalled();
});
});
describe('decodeFileNodeName at the API boundary (#869)', () => {
it('decodes percent-encoded names from getAllFileNodes and the cross-account list', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/get', {
list: [
{ id: 'n1', name: 'Spares%20Catalog', blobId: null },
{ id: 'n2', name: '100% done.txt', blobId: 'b2' },
],
}, '0']],
});
const own = await getAllFileNodes();
expect(own.map((n) => n.name)).toEqual(['Spares Catalog', '100% done.txt']);
const all = await getAllFileNodesAcrossAccounts();
expect(all.map((n) => n.name)).toEqual(['Spares Catalog', '100% done.txt']);
});
it('requests the real "modified" property, not "updated" (#700)', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/get', { list: [] }, '0']] });
await getAllFileNodes();
const [, args] = mockRequest.mock.calls[0][0][0];
expect(args.properties).toContain('modified');
expect(args.properties).not.toContain('updated');
});
});
describe('copyFileNode', () => {
it('creates a new node that reuses the blob instead of re-uploading', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { created: { 'new-file': { id: 'copy-1' } } }, '0']],
});
const copy = await copyFileNode(
{ id: 'f1', name: 'report.pdf', type: 'application/pdf', blobId: 'blob-1', size: 10, parentId: 'dir-1' },
'dir-1',
'report (1).pdf',
);
const [method, args] = mockRequest.mock.calls[0][0][0];
expect(method).toBe('FileNode/set');
expect(args.create['new-file']).toEqual({
name: 'report (1).pdf', type: 'application/pdf', blobId: 'blob-1', size: 10, parentId: 'dir-1',
});
expect(copy.id).toBe('copy-1');
});
it('refuses to duplicate folders', async () => {
await expect(copyFileNode({ id: 'd', name: 'Docs', type: '', blobId: null }, null)).rejects.toThrow();
expect(mockRequest).not.toHaveBeenCalled();
});
});
describe('moveFileNode', () => {
it('sends an explicit null parentId when moving to the root', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', { updated: { f1: null } }, '0']] });
await moveFileNode('f1', null);
const [, args] = mockRequest.mock.calls[0][0][0];
expect(args.update.f1).toEqual({ parentId: null });
});
});
describe('accountSupportsFiles (#563)', () => {
const caps = { [CAPABILITIES.FILES]: {} };
it('requires the account capability on personal accounts', () => {
expect(accountSupportsFiles({ name: 'me', isPersonal: true, isReadOnly: false }, caps)).toBe(false);
expect(accountSupportsFiles(
{ name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.FILES]: {} } },
caps,
)).toBe(true);
});
it('treats non-personal accounts as capable and needs the session capability', () => {
expect(accountSupportsFiles({ name: 'grp', isPersonal: false, isReadOnly: false }, caps)).toBe(true);
expect(accountSupportsFiles({ name: 'grp', isPersonal: false, isReadOnly: false }, {})).toBe(false);
expect(accountSupportsFiles(undefined, caps)).toBe(false);
});
});
describe('supportsSharing', () => {
it('accepts principals:owner from the session or the account capabilities', () => {
const mockHasAccountCapability = jmapClient.hasAccountCapability as ReturnType<typeof vi.fn>;
mockHasCapability.mockImplementation((urn: string) => urn === CAPABILITIES.FILES);
mockHasAccountCapability.mockReturnValue(false);
expect(supportsSharing()).toBe(false);
mockHasAccountCapability.mockImplementation((urn: string) => urn === CAPABILITIES.PRINCIPALS_OWNER);
expect(supportsSharing()).toBe(true);
mockHasAccountCapability.mockReturnValue(false);
mockHasCapability.mockImplementation(
(urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS_OWNER,
);
expect(supportsSharing()).toBe(true);
});
});
+9 -3
View File
@@ -36,9 +36,15 @@ describe('identity operations', () => {
describe('createIdentity', () => {
it('should create a new identity', async () => {
const created = { id: 'id-new', name: 'Work', email: 'work@example.com' };
mockRequest.mockResolvedValue({
methodResponses: [['Identity/set', { created: { 'new-identity': created } }, '0']],
});
// The create echo carries only server-set properties; the API re-reads
// the full identity afterwards.
mockRequest
.mockResolvedValueOnce({
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'id-new' } } }, '0']],
})
.mockResolvedValueOnce({
methodResponses: [['Identity/get', { list: [created] }, '0']],
});
const result = await createIdentity({ name: 'Work', email: 'work@example.com' });
expect(result).toEqual(created);
@@ -0,0 +1,375 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(),
getItemAsync: vi.fn(),
deleteItemAsync: vi.fn(),
}));
const { mockRefresh } = vi.hoisted(() => ({ mockRefresh: vi.fn() }));
vi.mock('../../lib/oauth', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../lib/oauth')>();
return { ...actual, refreshOAuthAccessToken: mockRefresh };
});
import {
JMAPClient,
AuthenticationError,
NetworkError,
RateLimitError,
RequestTimeoutError,
TotpRequiredError,
JMAPMethodError,
requireMethodResult,
assertSetResult,
rewriteSessionUrl,
parseRetryAfter,
isConcurrentRequestRefusal,
batched,
} from '../jmap-client';
import { TransientRefreshError } from '../../lib/oauth';
import { FirstTouchGate, gateKeysFor } from '../first-touch-gate';
import type { JMAPSession } from '../types';
const SESSION: JMAPSession = {
apiUrl: 'https://mail.example.com/jmap/',
downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}',
uploadUrl: 'https://mail.example.com/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-1' },
accounts: {
'acc-1': {
name: 'user@example.com',
isPersonal: true,
isReadOnly: false,
accountCapabilities: { 'urn:stalwart:jmap': {} },
},
},
capabilities: {
'urn:ietf:params:jmap:core': { maxObjectsInSet: 50, maxSizeUpload: 1024 },
'urn:ietf:params:jmap:mail': {},
},
state: 's1',
username: 'user@example.com',
};
type Resp = {
status: number;
json?: unknown;
text?: string;
headers?: Record<string, string>;
redirected?: boolean;
url?: string;
delayMs?: number;
throw?: Error;
};
function makeResponse(resp: Resp) {
const text = resp.text ?? (resp.json === undefined ? '' : JSON.stringify(resp.json));
return {
ok: resp.status >= 200 && resp.status < 300,
status: resp.status,
statusText: 'x',
redirected: resp.redirected ?? false,
url: resp.url ?? '',
headers: { get: (n: string) => resp.headers?.[n] ?? null },
json: async () => (resp.json !== undefined ? resp.json : JSON.parse(text)),
text: async () => text,
arrayBuffer: async () => new ArrayBuffer(0),
};
}
function mockFetch(responses: Resp[]) {
let i = 0;
const fn = vi.fn(async (_url: string, init?: RequestInit) => {
const resp = responses[i++] ?? responses[responses.length - 1];
if (resp.throw) throw resp.throw;
if (resp.delayMs) {
await new Promise<void>((resolve, reject) => {
const t = setTimeout(resolve, resp.delayMs);
init?.signal?.addEventListener('abort', () => {
clearTimeout(t);
const e = new Error('Aborted');
e.name = 'AbortError';
reject(e);
});
});
}
return makeResponse(resp);
});
global.fetch = fn as unknown as typeof fetch;
return fn;
}
async function connected(): Promise<JMAPClient> {
mockFetch([{ status: 200, json: SESSION }]);
const client = new JMAPClient();
await client.connect('https://mail.example.com', 'user', 'pass');
return client;
}
describe('pure helpers', () => {
it('rewriteSessionUrl rebases relative and foreign-origin URLs, keeps templates', () => {
const origin = 'https://mail.example.com';
expect(rewriteSessionUrl('/jmap/', origin)).toBe('https://mail.example.com/jmap/');
expect(rewriteSessionUrl('jmap/', origin)).toBe('https://mail.example.com/jmap/');
expect(rewriteSessionUrl('http://localhost:8080/download/{accountId}/{blobId}/{name}?type={type}', origin))
.toBe('https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}');
expect(rewriteSessionUrl('https://mail.example.com/x', origin)).toBe('https://mail.example.com/x');
expect(rewriteSessionUrl(undefined, origin)).toBeUndefined();
});
it('parseRetryAfter handles seconds, HTTP-date, garbage', () => {
expect(parseRetryAfter('10')).toBe(10_000);
expect(parseRetryAfter('9999')).toBe(300_000);
const future = new Date(Date.now() + 30_000).toUTCString();
const ms = parseRetryAfter(future);
expect(ms).toBeGreaterThan(25_000);
expect(ms).toBeLessThanOrEqual(31_000);
expect(parseRetryAfter('bogus')).toBe(60_000);
expect(parseRetryAfter(null)).toBe(60_000);
});
it('isConcurrentRequestRefusal matches only the limit error', () => {
expect(isConcurrentRequestRefusal(400, JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', limit: 'maxConcurrentRequests' }))).toBe(true);
expect(isConcurrentRequestRefusal(400, JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', limit: 'maxSizeRequest' }))).toBe(false);
expect(isConcurrentRequestRefusal(500, 'nope')).toBe(false);
});
it('requireMethodResult throws JMAPMethodError on the error envelope', () => {
const res = { methodResponses: [['error', { type: 'forbidden', description: 'no' }, '0']] as Array<[string, Record<string, any>, string]> };
expect(() => requireMethodResult(res, '0')).toThrow(JMAPMethodError);
expect(() => requireMethodResult(res)).toThrow('no');
const ok = { methodResponses: [['Mailbox/get', { list: [1] }, 'a']] as Array<[string, Record<string, any>, string]> };
expect(requireMethodResult(ok, 'a', 'Mailbox/get')).toEqual({ list: [1] });
expect(() => requireMethodResult(ok, 'a', 'Email/get')).toThrow('Expected Email/get');
expect(() => requireMethodResult(ok, 'zz')).toThrow('Missing JMAP response');
});
it('assertSetResult throws for notUpdated / notDestroyed / notCreated', () => {
expect(() => assertSetResult({ updated: { a: null } }, ['a'])).not.toThrow();
expect(() => assertSetResult({ notUpdated: { a: { type: 'forbidden' } } }, ['a'])).toThrow('update');
expect(() => assertSetResult({ notUpdated: { b: { type: 'forbidden' } } }, ['a'])).not.toThrow();
expect(() => assertSetResult({ notDestroyed: { a: { type: 'notFound', description: 'gone' } } })).toThrow('gone');
expect(() => assertSetResult({ notCreated: { x: { type: 'invalidProperties', properties: ['uid'] } } }, undefined, 'contact')).toThrow('properties=[uid]');
});
it('batched splits lists', () => {
expect(batched([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
expect(batched([], 2)).toEqual([]);
});
});
describe('FirstTouchGate', () => {
it('serialises the first calendar/contacts request per account', async () => {
const gate = new FirstTouchGate();
const order: string[] = [];
let release!: () => void;
const first = gate.run([['Calendar/get', { accountId: 'a' }, '0']], () => new Promise<string>((resolve) => {
release = () => { order.push('first'); resolve('first'); };
}));
const second = gate.run([['CalendarEvent/query', { accountId: 'a' }, '0']], async () => { order.push('second'); return 'second'; });
const other = gate.run([['Mailbox/get', { accountId: 'a' }, '0']], async () => { order.push('mail'); return 'mail'; });
await other;
expect(order).toEqual(['mail']);
release();
await Promise.all([first, second]);
expect(order).toEqual(['mail', 'first', 'second']);
expect(gateKeysFor([['AddressBook/get', { accountId: 'b' }, '0'], ['Email/get', { accountId: 'b' }, '1']])).toEqual(['contacts:b']);
});
});
describe('JMAPClient hardening', () => {
beforeEach(() => {
vi.restoreAllMocks();
mockRefresh.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('resolves relative session URLs against the login origin', async () => {
mockFetch([{ status: 200, json: { ...SESSION, apiUrl: '/jmap/', downloadUrl: '/download/{accountId}/{blobId}/{name}?type={type}' } }]);
const client = new JMAPClient();
const session = await client.connect('https://mail.example.com', 'user', 'pass');
expect(session.apiUrl).toBe('https://mail.example.com/jmap/');
expect(client.getBlobDownloadUrl('b1', 'n')).toBe('https://mail.example.com/download/acc-1/b1/n?type=application%2Foctet-stream');
});
it('refetches the final URL when a redirect dropped the Authorization header', async () => {
const fetch = mockFetch([
{ status: 200, json: { apiUrl: '/jmap/', accounts: {}, primaryAccounts: {} }, redirected: true, url: 'https://mail.example.com/jmap/session' },
{ status: 200, json: SESSION },
]);
const client = new JMAPClient();
await client.connect('https://mail.example.com', 'user', 'pass');
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch.mock.calls[1][0]).toBe('https://mail.example.com/jmap/session');
expect(client.accountId).toBe('acc-1');
});
it('throws TotpRequiredError on a 402 MFA challenge', async () => {
mockFetch([{ status: 402, json: { title: 'MFA code required' } }]);
const client = new JMAPClient();
const err = await client.connect('https://mail.example.com', 'user', 'pass').catch((e) => e);
expect(err).toBeInstanceOf(TotpRequiredError);
expect(err).toBeInstanceOf(AuthenticationError);
});
it('times out a stalled request with RequestTimeoutError and does not retry it', async () => {
vi.useFakeTimers();
const client = await connected();
const fetch = mockFetch([{ status: 200, json: { methodResponses: [] }, delayMs: 60_000 }]);
const p = client.request([['EmailSubmission/set', { accountId: 'acc-1' }, '0']]);
const settled = p.catch((e) => e);
await vi.advanceTimersByTimeAsync(31_000);
const err = await settled;
expect(err).toBeInstanceOf(RequestTimeoutError);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('retries an idempotent request once after a transient network error', async () => {
vi.useFakeTimers();
const client = await connected();
const fetch = mockFetch([
{ status: 200, throw: new TypeError('Network request failed') },
{ status: 200, json: { methodResponses: [['Mailbox/get', { list: [] }, '0']] } },
]);
const p = client.request([['Mailbox/get', { accountId: 'acc-1' }, '0']]);
await vi.advanceTimersByTimeAsync(1100);
const res = await p;
expect(res.methodResponses[0][0]).toBe('Mailbox/get');
expect(fetch).toHaveBeenCalledTimes(2);
});
it('does not retry a submission after a network error and wraps it as NetworkError', async () => {
const client = await connected();
const fetch = mockFetch([{ status: 200, throw: new TypeError('Network request failed') }]);
const err = await client.request([['EmailSubmission/set', { accountId: 'acc-1' }, '0']]).catch((e) => e);
expect(err).toBeInstanceOf(NetworkError);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('replays a maxConcurrentRequests refusal with back-off', async () => {
vi.useFakeTimers();
const client = await connected();
const refusal = { type: 'urn:ietf:params:jmap:error:limit', limit: 'maxConcurrentRequests' };
const fetch = mockFetch([
{ status: 400, json: refusal },
{ status: 400, json: refusal },
{ status: 200, json: { methodResponses: [['Email/set', { updated: {} }, '0']] } },
]);
const p = client.request([['Email/set', { accountId: 'acc-1' }, '0']]);
await vi.advanceTimersByTimeAsync(3000);
await p;
expect(fetch).toHaveBeenCalledTimes(3);
});
it('surfaces the response body on a failed request and guards JSON parsing', async () => {
const client = await connected();
mockFetch([{ status: 500, text: 'boom happened' }]);
await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow('JMAP request failed: 500 - boom happened');
mockFetch([{ status: 200, text: '<html>not json' }]);
await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow('Invalid JSON');
});
it('enters a rate-limit window on 429 (HTTP-date) and short-circuits until it ends', async () => {
const client = await connected();
const onRateLimit = vi.fn();
client.onRateLimit(onRateLimit);
const fetch = mockFetch([{ status: 429, headers: { 'Retry-After': new Date(Date.now() + 20_000).toUTCString() } }]);
const err = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e);
expect(err).toBeInstanceOf(RateLimitError);
expect(Number.isFinite((err as RateLimitError).retryAfterMs)).toBe(true);
expect((err as RateLimitError).retryAfterMs).toBeGreaterThan(10_000);
expect(onRateLimit).toHaveBeenCalledTimes(1);
expect(client.isRateLimited()).toBe(true);
const err2 = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e);
expect(err2).toBeInstanceOf(RateLimitError);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('notifies auth-failure listeners on a 401 during a request', async () => {
const client = await connected();
const onAuthFailure = vi.fn();
client.onAuthFailure(onAuthFailure);
mockFetch([{ status: 401 }]);
await expect(client.request([['Mailbox/get', {}, '0']])).rejects.toThrow(AuthenticationError);
expect(onAuthFailure).toHaveBeenCalledTimes(1);
});
it('keeps the account when the token endpoint is transiently down', async () => {
const client = new JMAPClient();
mockFetch([{ status: 200, json: SESSION }]);
await client.connectWithOAuth('https://mail.example.com', {
accessToken: 'old',
refreshToken: 'r',
expiresAt: Date.now() + 3_600_000,
tokenEndpoint: 'https://mail.example.com/auth/token',
clientId: 'c',
});
mockRefresh.mockRejectedValue(new TransientRefreshError('503'));
mockFetch([{ status: 401 }]);
const err = await client.request([['Mailbox/get', {}, '0']]).catch((e) => e);
expect(err).toBeInstanceOf(NetworkError);
expect(err).not.toBeInstanceOf(AuthenticationError);
});
it('fires token-refresh listeners and retries with the new bearer after a 401', async () => {
const client = new JMAPClient();
mockFetch([{ status: 200, json: SESSION }]);
await client.connectWithOAuth('https://mail.example.com', {
accessToken: 'old',
refreshToken: 'r',
expiresAt: Date.now() + 3_600_000,
tokenEndpoint: 'https://mail.example.com/auth/token',
clientId: 'c',
});
const onRefresh = vi.fn();
client.onTokenRefresh(onRefresh);
mockRefresh.mockResolvedValue({
accessToken: 'new',
refreshToken: 'r2',
expiresAt: Date.now() + 3_600_000,
tokenEndpoint: 'https://mail.example.com/auth/token',
clientId: 'c',
});
const fetch = mockFetch([
{ status: 401 },
{ status: 200, json: { methodResponses: [['Mailbox/get', {}, '0']] } },
]);
await client.request([['Mailbox/get', {}, '0']]);
expect(onRefresh).toHaveBeenCalledTimes(1);
expect((fetch.mock.calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer new' });
expect(client.authHeader).toBe('Bearer new');
});
it('updatePassword rewrites the stored credential', async () => {
const SecureStore = await import('expo-secure-store');
const client = await connected();
(SecureStore.setItemAsync as ReturnType<typeof vi.fn>).mockClear();
await client.updatePassword('new-pass');
expect(client.authHeader).toBe(`Basic ${btoa('user:new-pass')}`);
expect(SecureStore.setItemAsync).toHaveBeenCalledTimes(1);
const saved = JSON.parse((SecureStore.setItemAsync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string);
expect(saved.password).toBe('new-pass');
});
it('exposes account-level capabilities and set/upload limits', async () => {
const client = await connected();
expect(client.hasAccountCapability('urn:stalwart:jmap')).toBe(true);
expect(client.hasCapability('urn:stalwart:jmap')).toBe(false);
expect(client.getMaxObjectsInSet()).toBe(50);
expect(client.getMaxSizeUpload()).toBe(1024);
});
it('snapshot/restoreSnapshot round-trips the live connection', async () => {
const client = await connected();
const snap = client.snapshot();
client.reset();
expect(client.isConnected).toBe(false);
client.restoreSnapshot(snap);
expect(client.isConnected).toBe(true);
expect(client.accountId).toBe('acc-1');
});
});
+6 -3
View File
@@ -42,6 +42,7 @@ function mockFetch(responses: Array<{ status: number; json?: any; headers?: Reco
get: (name: string) => resp.headers?.[name] ?? null,
},
json: async () => resp.json,
text: async () => (resp.json === undefined ? '' : JSON.stringify(resp.json)),
};
});
}
@@ -90,7 +91,7 @@ describe('JMAPClient', () => {
await client.connect('https://mail.example.com///', 'user', 'pass');
expect(global.fetch).toHaveBeenCalledWith(
'https://mail.example.com/.well-known/jmap',
'https://mail.example.com/jmap/session',
expect.any(Object),
);
});
@@ -281,7 +282,9 @@ describe('JMAPClient', () => {
expect(client.accountId).toBe('only-acc');
});
it('should throw if no accounts at all', async () => {
it('should throw on an empty (unauthenticated) session', async () => {
// Stalwart returns 200 with empty accounts for invalid credentials;
// the client surfaces that as an auth failure.
const session = {
...MOCK_SESSION,
primaryAccounts: {},
@@ -290,7 +293,7 @@ describe('JMAPClient', () => {
global.fetch = mockFetch([{ status: 200, json: session }]) as any;
await expect(client.connect('https://mail.example.com', 'user', 'pass'))
.rejects.toThrow('No account found');
.rejects.toThrow('Invalid credentials');
});
});
});
+291
View File
@@ -0,0 +1,291 @@
/**
* Live integration test against a real Stalwart server.
*
* Opt-in: skipped unless `BULWARK_LIVE_JMAP` names a reachable JMAP host, so
* the normal suite stays hermetic. It exercises the client code paths that
* cannot be proven with mocks - the ones where a fixture only ever encodes
* what we already believed the server does:
*
* BULWARK_LIVE_JMAP=http://127.0.0.1:18081 \
* BULWARK_LIVE_USER=usera@example.org \
* BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart.test.ts
*
* Verified against stalwartlabs/stalwart:v0.16.19.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { JMAPClient } from '../jmap-client';
import {
getMailboxes,
queryEmails,
getFullEmails,
getThreadEmails,
createDraft,
sendEmail,
markAsSpam,
undoSpam,
emptyMailbox,
markMailboxAsRead,
createMailbox,
deleteMailbox,
patchKeywordsForEmails,
EMAIL_FULL_PROPERTIES,
} from '../email';
import { getIdentities } from '../identity';
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const live = SERVER && PASS ? describe : describe.skip;
// The api modules talk to the exported singleton; swap its guts for a client
// connected to the live server.
let client: JMAPClient;
async function connectSingleton(): Promise<void> {
const mod = await import('../jmap-client');
client = new JMAPClient();
await client.connect(SERVER!, USER, PASS);
// The singleton is a const binding; copy the connected state onto it.
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
}
live('live Stalwart', () => {
let inbox = '';
let drafts = '';
let sent = '';
let junk = '';
beforeAll(async () => {
await connectSingleton();
const boxes = await getMailboxes();
const byRole = (r: string) => boxes.find((b) => b.role === r)?.id ?? '';
inbox = byRole('inbox');
drafts = byRole('drafts');
sent = byRole('sent');
junk = byRole('junk');
expect(inbox).toBeTruthy();
}, 60_000);
describe('session', () => {
it('rebases the advertised session URLs onto the connected origin', () => {
// Stalwart advertises its configured hostname (https://stwtest.local/…),
// which is unreachable from a client that connected by IP/port. Without
// the rewrite every request after login fails.
const session = client.currentSession!;
expect(session.apiUrl.startsWith(SERVER!)).toBe(true);
expect(session.downloadUrl.startsWith(SERVER!)).toBe(true);
// …and the RFC 6570 templates must survive the rewrite.
expect(session.downloadUrl).toContain('{accountId}');
expect(session.downloadUrl).toContain('{blobId}');
expect(session.eventSourceUrl).toContain('{types}');
});
it('finds the Stalwart extension in accountCapabilities, not session capabilities (native #47)', () => {
const session = client.currentSession!;
expect('urn:stalwart:jmap' in (session.capabilities ?? {})).toBe(false);
expect(client.hasAccountCapability('urn:stalwart:jmap')).toBe(true);
// The pre-fix session-level check is what made Account Security dead.
expect(client.hasCapability('urn:stalwart:jmap')).toBe(false);
});
it('reads the advertised request limits', () => {
expect(client.getMaxObjectsInGet()).toBeGreaterThan(0);
expect(client.getMaxObjectsInSet()).toBeGreaterThan(0);
expect(client.getMaxSizeUpload()).toBeGreaterThan(0);
});
it('rejects bad credentials with AuthenticationError', async () => {
const bad = new JMAPClient();
await expect(bad.connect(SERVER!, USER, 'definitely-not-the-password'))
.rejects.toMatchObject({ name: 'AuthenticationError' });
}, 30_000);
});
describe('mail', () => {
it('fetches the threading headers the composer needs (#234)', async () => {
expect(EMAIL_FULL_PROPERTIES).toContain('messageId');
expect(EMAIL_FULL_PROPERTIES).toContain('references');
expect(EMAIL_FULL_PROPERTIES).toContain('headers');
const { ids } = await queryEmails(inbox, { limit: 20 });
const emails = await getFullEmails(ids);
const reply = emails.find((e) => (e.subject ?? '').startsWith('Re: '));
expect(reply, 'seeded reply message missing').toBeTruthy();
// Bare msg-ids, no angle brackets (RFC 8621 §4.1.2.3).
expect(reply!.messageId?.[0]).toMatch(/^[^<>]+@[^<>]+$/);
expect(reply!.references?.length).toBeGreaterThan(0);
expect(Array.isArray(reply!.headers)).toBe(true);
}, 60_000);
it('exposes an HTML-only body as the same part in textBody and htmlBody', async () => {
const { ids } = await queryEmails(inbox, { limit: 20 });
const emails = await getFullEmails(ids);
const news = emails.find((e) => (e.subject ?? '').includes('August digest'));
expect(news, 'seeded HTML-only message missing').toBeTruthy();
const textPart = news!.textBody?.[0];
const htmlPart = news!.htmlBody?.[0];
// The native #46 shape: identical partId, and the part is text/html.
expect(textPart?.partId).toBe(htmlPart?.partId);
expect(htmlPart?.type).toBe('text/html');
// A plain-text message reports the same partId too - only `type` tells
// them apart, which is why the picker must route by type.
const plain = emails.find((e) => (e.subject ?? '') === 'Quarterly numbers');
expect(plain!.textBody?.[0]?.partId).toBe(plain!.htmlBody?.[0]?.partId);
expect(plain!.htmlBody?.[0]?.type).toBe('text/plain');
}, 60_000);
it('returns every message of a thread, oldest first', async () => {
const { ids } = await queryEmails(inbox, { limit: 20 });
const emails = await getFullEmails(ids);
const reply = emails.find((e) => (e.subject ?? '').startsWith('Re: '))!;
const thread = await getThreadEmails(reply.threadId);
expect(thread.length).toBeGreaterThanOrEqual(2);
const times = thread.map((e) => new Date(e.receivedAt).getTime());
expect([...times].sort((a, b) => a - b)).toEqual(times);
}, 60_000);
it('saves a draft into Drafts with $draft', async () => {
const id = await createDraft(
{
from: [{ email: USER }],
to: [{ email: 'userb@example.org' }],
subject: 'Draft from the live test',
textBody: 'draft body',
},
drafts,
);
const [saved] = await getFullEmails([id]);
expect(saved.mailboxIds[drafts]).toBe(true);
expect(saved.keywords.$draft).toBe(true);
// Message-ID is generated client-side from the sender's domain.
expect(saved.messageId?.[0]).toContain('@example.org');
await patchKeywordsForEmails([id], { $seen: true });
const { destroyEmails } = await import('../email');
await destroyEmails([id]);
}, 60_000);
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
const identities = await getIdentities();
expect(identities.length).toBeGreaterThan(0);
const result = await sendEmail(
{
from: [{ email: USER }],
to: [{ email: 'userb@example.org' }],
subject: 'Live send test',
textBody: 'sent by the live integration test',
inReplyTo: ['<root-1@partner.example>'],
references: ['<root-1@partner.example>'],
},
identities[0].id,
sent,
undefined,
{ draftsMailboxId: drafts },
);
expect(result.emailId).toBeTruthy();
expect(result.emailSubmissionId).toBeTruthy();
expect(result.filingWarning).toBeUndefined();
const [filed] = await getFullEmails([result.emailId!]);
// onSuccessUpdateEmail moved it out of Drafts into Sent.
expect(filed.mailboxIds[sent]).toBe(true);
expect(filed.mailboxIds[drafts]).toBeUndefined();
expect(filed.keywords.$draft).toBeUndefined();
// Threading headers went out as bare msg-ids, not the JMAP id.
expect(filed.inReplyTo).toEqual(['root-1@partner.example']);
expect(filed.references).toEqual(['root-1@partner.example']);
}, 90_000);
it('flips $junk/$notjunk when filing spam and undoing it (#850)', async () => {
const { ids } = await queryEmails(inbox, { limit: 5 });
const target = ids[0];
await markAsSpam([target], junk, undefined, { markRead: true });
let [msg] = await getFullEmails([target]);
expect(msg.mailboxIds[junk]).toBe(true);
expect(msg.keywords.$junk).toBe(true);
expect(msg.keywords.$seen).toBe(true);
await undoSpam([target], inbox);
[msg] = await getFullEmails([target]);
expect(msg.mailboxIds[inbox]).toBe(true);
expect(msg.mailboxIds[junk]).toBeUndefined();
expect(msg.keywords.$junk).toBeUndefined();
expect(msg.keywords.$notjunk).toBe(true);
}, 90_000);
it('empties a folder and marks one read in batches', async () => {
const scratch = await createMailbox({ name: `live-test-${Date.now()}` });
try {
// Nothing in it yet: the loop must terminate on an empty page.
expect(await emptyMailbox(scratch)).toBe(0);
expect(await markMailboxAsRead(scratch)).toBe(0);
} finally {
await deleteMailbox(scratch);
}
}, 60_000);
});
describe('calendar', () => {
it('queries a date window with the singular inCalendar filter', async () => {
const cals = await getCalendars();
expect(cals.length).toBeGreaterThan(0);
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString();
const to = new Date(now.getFullYear(), now.getMonth() + 2, 0).toISOString();
const ids = await queryEvents(cals.map((c) => c.originalId ?? c.id), from, to);
expect(Array.isArray(ids)).toBe(true);
expect(ids.length).toBeGreaterThan(0);
}, 60_000);
it('creates a timed event with a time zone and destroys it', async () => {
const cals = await getCalendars();
const cal = cals[0];
const start = new Date(Date.now() + 86_400_000);
const created = await createEvent(
{
title: 'Live test event',
start: start.toISOString().replace(/\.\d+Z$/, '').replace('T', 'T'),
duration: 'PT1H',
timeZone: 'Europe/Berlin',
},
cal.originalId ?? cal.id,
);
expect(created.id).toBeTruthy();
await deleteEvents([created.id]);
}, 60_000);
});
describe('contacts', () => {
it('creates a card with a UID and an RFC 9553 PartialDate birthday (#644, #224)', async () => {
const books = await getAddressBooks();
expect(books.length).toBeGreaterThan(0);
const book = books[0];
const created = await createContact(
{
name: { components: [{ kind: 'given', value: 'Live' }, { kind: 'surname', value: 'Test' }] },
emails: { e1: { address: `live-${Date.now()}@example.org` } },
anniversaries: {
a1: {
'@type': 'Anniversary',
kind: 'birth',
// A raw "1985-04-12" string is rejected by Stalwart (#224).
date: { '@type': 'PartialDate', year: 1985, month: 4, day: 12 },
},
},
},
book.originalId ?? book.id,
);
try {
expect(created.id).toBeTruthy();
// createContact re-reads the card, so the caller gets a full object.
expect(created.uid).toBeTruthy();
expect(created.name?.components?.length).toBeGreaterThan(0);
} finally {
await deleteContacts([created.id]);
}
}, 60_000);
});
});
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
},
}));
import { jmapClient } from '../jmap-client';
import { queryRecentRecipients, searchSentRecipients } from '../recent-recipients';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe('queryRecentRecipients', () => {
it('scans Sent To/Cc newest-first and keeps the first name per address', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Email/query', { ids: ['m1', 'm2'] }, '0'],
['Email/get', {
list: [
{ id: 'm1', to: [{ name: 'Jane Doe', email: 'jane@x.com' }], cc: [{ email: 'cc@x.com' }] },
{ id: 'm2', to: [{ name: 'Old Jane', email: 'JANE@x.com' }, { name: 'Bob', email: 'bob@x.com' }] },
],
}, '1'],
],
});
const result = await queryRecentRecipients('sent-1');
expect(result).toEqual([
{ name: 'Jane Doe', email: 'jane@x.com' },
{ name: '', email: 'cc@x.com' },
{ name: 'Bob', email: 'bob@x.com' },
]);
const [query, get] = mockRequest.mock.calls[0][0];
expect(query[1]).toMatchObject({
accountId: 'acc-1',
filter: { inMailbox: 'sent-1' },
sort: [{ property: 'receivedAt', isAscending: false }],
limit: 300,
});
expect(get[1].properties).toEqual(['to', 'cc']);
expect(get[1]['#ids']).toEqual({ resultOf: '0', name: 'Email/query', path: '/ids' });
});
it('returns nothing without a Sent mailbox', async () => {
expect(await queryRecentRecipients('')).toEqual([]);
expect(mockRequest).not.toHaveBeenCalled();
});
});
describe('searchSentRecipients', () => {
it('only keeps the recipients that match the query', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Email/query', { ids: ['m1'] }, '0'],
['Email/get', {
list: [{ id: 'm1', to: [{ name: 'Jane', email: 'jane@x.com' }, { name: 'Bob', email: 'bob@x.com' }] }],
}, '1'],
],
});
expect(await searchSentRecipients('jan', 'sent-1')).toEqual([{ name: 'Jane', email: 'jane@x.com' }]);
const query = mockRequest.mock.calls[0][0][0];
expect(query[1].filter).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'sent-1' }, { operator: 'OR', conditions: [{ to: 'jan' }, { cc: 'jan' }] }],
});
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
fetchLatestRelease,
parseSeverity,
parseAdvisoryUrl,
resolveApkSha256,
stripMarkdown,
UpdateRateLimitedError,
type LatestRelease,
} from '../updates';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
describe('parseSeverity', () => {
it('reads a severity line from the release body', () => {
expect(parseSeverity('## Notes\nseverity: security\n- fix')).toBe('security');
expect(parseSeverity('- Severity: Deprecated')).toBe('deprecated');
expect(parseSeverity('nothing here')).toBe('normal');
expect(parseSeverity('severity: bogus')).toBe('normal');
});
it('reads an advisory url', () => {
expect(parseAdvisoryUrl('advisory: https://github.com/x/y/security/advisories/GHSA-1')).toBe(
'https://github.com/x/y/security/advisories/GHSA-1',
);
expect(parseAdvisoryUrl('')).toBeNull();
});
});
describe('stripMarkdown', () => {
it('unwraps headings, emphasis, links and bullets and drops control lines', () => {
const out = stripMarkdown('# Title\nseverity: security\n\n- **bold** and [link](https://x.y) `code`\n> quote');
expect(out).toBe('Title\n\n• bold and link code\nquote');
});
});
describe('fetchLatestRelease', () => {
const release = (overrides: Record<string, unknown> = {}) => ({
tag_name: '1.2.3',
name: 'v1.2.3',
html_url: 'https://github.com/bulwarkmail/native/releases/tag/1.2.3',
published_at: '2026-01-01T00:00:00Z',
body: '',
assets: [
{ name: 'app.apk', browser_download_url: 'https://x/app.apk', size: 10 },
{ name: 'app.apk.sha256', browser_download_url: 'https://x/app.apk.sha256', size: 65 },
],
...overrides,
});
it('does not download the checksum companion during the check', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => release() }));
global.fetch = fetchMock as unknown as typeof fetch;
const latest = await fetchLatestRelease();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(latest?.sha256Asset?.name).toBe('app.apk.sha256');
expect(latest?.apkSha256).toBeNull();
expect(latest?.severity).toBe('normal');
});
it('parses severity from the body', async () => {
global.fetch = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => release({ body: 'severity: security\nadvisory: https://a.b/c' }),
})) as unknown as typeof fetch;
const latest = await fetchLatestRelease();
expect(latest?.severity).toBe('security');
expect(latest?.advisoryUrl).toBe('https://a.b/c');
});
it('throws UpdateRateLimitedError on 403/429', async () => {
global.fetch = vi.fn(async () => ({ ok: false, status: 403 })) as unknown as typeof fetch;
await expect(fetchLatestRelease()).rejects.toBeInstanceOf(UpdateRateLimitedError);
global.fetch = vi.fn(async () => ({ ok: false, status: 429 })) as unknown as typeof fetch;
await expect(fetchLatestRelease()).rejects.toBeInstanceOf(UpdateRateLimitedError);
});
});
describe('resolveApkSha256', () => {
const base: LatestRelease = {
tag: '1', name: '1', htmlUrl: '', publishedAt: '', body: '',
apkAsset: null, sha256Asset: null, apkSha256: null, severity: 'normal', advisoryUrl: null,
};
it('prefers the body hash and otherwise fetches the companion asset', async () => {
const hash = 'a'.repeat(64);
expect(await resolveApkSha256({ ...base, apkSha256: hash })).toBe(hash);
global.fetch = vi.fn(async () => ({ ok: true, status: 200, text: async () => `${'b'.repeat(64)} app.apk` })) as unknown as typeof fetch;
expect(await resolveApkSha256({
...base,
sha256Asset: { name: 'app.apk.sha256', browser_download_url: 'https://x', size: 1 },
})).toBe('b'.repeat(64));
expect(await resolveApkSha256(base)).toBeNull();
});
});
+410
View File
@@ -0,0 +1,410 @@
// Stalwart account-security management. Mirrors the webmail's
// stores/account-security-store.ts, talking to Stalwart's JMAP extension
// methods (x:AccountPassword, x:AppPassword, x:ApiKey, x:AccountSettings,
// x:Account). The webmail proxies these through a server-side passthrough so
// the basic-auth header stays in an httpOnly cookie; the mobile client already
// holds its own credentials, so it calls the JMAP endpoint directly with the
// `urn:stalwart:jmap` capability in `using`.
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
const STALWART_CAPABILITY = 'urn:stalwart:jmap';
const STALWART_USING = [CAPABILITIES.CORE, STALWART_CAPABILITY];
export type EncryptionType = 'Disabled' | 'Aes128' | 'Aes256';
export interface AppCredentialInfo {
id: string;
description: string;
createdAt: string | null;
expiresAt: string | null;
allowedIps: string[];
}
export type AppPasswordInfo = AppCredentialInfo;
export type ApiKeyInfo = AppCredentialInfo;
export interface AppCredentialInput {
description: string;
expiresAt?: string | null;
allowedIps?: string[];
}
export interface AuthInfo {
otpEnabled: boolean;
appPasswords: AppPasswordInfo[];
apiKeys: ApiKeyInfo[];
}
export interface CryptoInfo {
type: EncryptionType;
publicKeyId: string | null;
encryptOnAppend: boolean;
allowSpamTraining: boolean;
}
export interface PublicKeyInfo {
id: string;
description: string;
emailAddresses: string[];
createdAt: string | null;
expiresAt: string | null;
// Key material (PEM / armored). Only returned by some server versions.
key?: string;
}
export interface PublicKeyInput {
description: string;
key: string;
emailAddresses?: string[];
expiresAt?: string | null;
}
export interface PrincipalInfo {
displayName: string;
emails: string[];
quota: number;
roles: string[];
}
type MethodResponse = [string, Record<string, any>, string];
// True once a live session advertises Stalwart's JMAP extension. Without it
// none of the x:* methods exist, so the screen shows a "not available" notice.
// Stalwart lists `urn:stalwart:jmap` only under the account's
// `accountCapabilities` (verified on 0.16.19), never at session level, so the
// account-level map is what has to be checked (native #47).
export function isStalwartSupported(): boolean {
const session = jmapClient.currentSession;
if (!session || !jmapClient.isConnected) return false;
return jmapClient.hasAccountCapability(STALWART_CAPABILITY);
}
// Pull a single method response out by its call id and throw on a JMAP method
// error (the `['error', {type, description}, id]` envelope).
function resultFor<T = Record<string, any>>(responses: MethodResponse[], callId: string): T {
const match = responses.find((r) => r[2] === callId);
if (!match) throw new Error(`Missing JMAP response for call ${callId}`);
if (match[0] === 'error') {
const err = match[1] as { type?: string; description?: string };
throw new Error(err.description || err.type || 'JMAP error');
}
return match[1] as T;
}
async function send(methodCalls: [string, Record<string, unknown>, string][]): Promise<MethodResponse[]> {
const res = await jmapClient.request(methodCalls, STALWART_USING);
return res.methodResponses as MethodResponse[];
}
function credentialFromResult(raw: Record<string, unknown>): AppCredentialInfo {
const allowedIps =
raw.allowedIps && typeof raw.allowedIps === 'object'
? Object.keys(raw.allowedIps as Record<string, unknown>)
: [];
return {
id: String(raw.id ?? ''),
description: typeof raw.description === 'string' ? raw.description : '',
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
allowedIps,
};
}
function ipsToMap(ips?: string[]): Record<string, true> | undefined {
if (!ips || ips.length === 0) return undefined;
return Object.fromEntries(ips.map((ip) => [ip, true]));
}
function buildCreateBody(input: AppCredentialInput): Record<string, unknown> {
const body: Record<string, unknown> = { description: input.description };
if (input.expiresAt) body.expiresAt = input.expiresAt;
const allowed = ipsToMap(input.allowedIps);
if (allowed) body.allowedIps = allowed;
return body;
}
function extractEncryptionType(raw: unknown): EncryptionType {
if (!raw || typeof raw !== 'object') return 'Disabled';
const type = (raw as { ['@type']?: string })['@type'];
if (type === 'Aes128' || type === 'Aes256') return type;
return 'Disabled';
}
// ── Reads ─────────────────────────────────────────────────
export async function fetchAuthInfo(): Promise<AuthInfo> {
const accountId = jmapClient.accountId;
const responses = await send([
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, 'p'],
['x:AppPassword/query', { accountId }, 'aq'],
['x:ApiKey/query', { accountId }, 'kq'],
]);
const passwordResult = resultFor<{ list?: Array<{ otpAuth?: { otpUrl?: string | null } }> }>(responses, 'p');
const appPwQuery = resultFor<{ ids?: string[] }>(responses, 'aq');
const apiKeyQuery = resultFor<{ ids?: string[] }>(responses, 'kq');
const otpAuth = passwordResult.list?.[0]?.otpAuth;
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl);
const followUps: [string, Record<string, unknown>, string][] = [];
if (appPwQuery.ids?.length) {
followUps.push(['x:AppPassword/get', { accountId, ids: appPwQuery.ids }, 'app']);
}
if (apiKeyQuery.ids?.length) {
followUps.push(['x:ApiKey/get', { accountId, ids: apiKeyQuery.ids }, 'key']);
}
let appPasswords: AppPasswordInfo[] = [];
let apiKeys: ApiKeyInfo[] = [];
if (followUps.length) {
const followUpResponses = await send(followUps);
if (appPwQuery.ids?.length) {
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'app');
appPasswords = (r.list ?? []).map(credentialFromResult);
}
if (apiKeyQuery.ids?.length) {
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'key');
apiKeys = (r.list ?? []).map(credentialFromResult);
}
}
return { otpEnabled, appPasswords, apiKeys };
}
export async function fetchEncryptionType(): Promise<EncryptionType> {
return (await fetchCryptoInfo()).type;
}
export async function fetchCryptoInfo(): Promise<CryptoInfo> {
const accountId = jmapClient.accountId;
const responses = await send([['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0']]);
const result = resultFor<{ list?: Array<{ encryptionAtRest?: unknown }> }>(responses, '0');
const raw = (result.list?.[0]?.encryptionAtRest ?? {}) as {
publicKey?: string | null;
encryptOnAppend?: boolean;
allowSpamTraining?: boolean;
};
return {
type: extractEncryptionType(result.list?.[0]?.encryptionAtRest),
publicKeyId: typeof raw.publicKey === 'string' ? raw.publicKey : null,
encryptOnAppend: raw.encryptOnAppend === true,
allowSpamTraining: raw.allowSpamTraining === true,
};
}
/**
* Configure Stalwart's encryption at rest (`x:AccountSettings/set`
* `encryptionAtRest`). `Aes128`/`Aes256` need a public key id from
* {@link fetchPublicKeys}; `encryptOnAppend` also encrypts mail the user
* files (sent/imported), `allowSpamTraining` lets the classifier read a
* message before it is encrypted.
*/
export async function updateEncryptionAtRest(input: {
type: EncryptionType;
publicKeyId?: string | null;
encryptOnAppend?: boolean;
allowSpamTraining?: boolean;
}): Promise<void> {
const accountId = jmapClient.accountId;
let payload: Record<string, unknown>;
if (input.type === 'Disabled') {
payload = { '@type': 'Disabled' };
} else {
if (!input.publicKeyId) throw new Error('A public key is required to enable encryption');
payload = {
'@type': input.type,
publicKey: input.publicKeyId,
encryptOnAppend: input.encryptOnAppend ?? false,
allowSpamTraining: input.allowSpamTraining ?? false,
};
}
const responses = await send([
['x:AccountSettings/set', { accountId, update: { singleton: { encryptionAtRest: payload } } }, '0'],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to update encryption settings');
}
function publicKeyFromResult(raw: Record<string, unknown>): PublicKeyInfo {
const emails =
raw.emailAddresses && typeof raw.emailAddresses === 'object'
? Object.keys(raw.emailAddresses as Record<string, unknown>)
: [];
return {
id: String(raw.id ?? ''),
description: typeof raw.description === 'string' ? raw.description : '',
emailAddresses: emails,
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
key: typeof raw.key === 'string' ? raw.key : undefined,
};
}
/** S/MIME certificates and PGP public keys stored on the server (`x:PublicKey`). */
export async function fetchPublicKeys(): Promise<PublicKeyInfo[]> {
const accountId = jmapClient.accountId;
const queryResponses = await send([['x:PublicKey/query', { accountId }, '0']]);
const query = resultFor<{ ids?: string[] }>(queryResponses, '0');
if (!query.ids?.length) return [];
const getResponses = await send([['x:PublicKey/get', { accountId, ids: query.ids }, '0']]);
const got = resultFor<{ list?: Array<Record<string, unknown>> }>(getResponses, '0');
return (got.list ?? []).map(publicKeyFromResult);
}
export async function createPublicKey(input: PublicKeyInput): Promise<string> {
const accountId = jmapClient.accountId;
const tmpId = 'new';
const payload: Record<string, unknown> = {
description: input.description,
key: input.key,
emailAddresses: ipsToMap(input.emailAddresses) ?? {},
};
if (input.expiresAt) payload.expiresAt = input.expiresAt;
const responses = await send([['x:PublicKey/set', { accountId, create: { [tmpId]: payload } }, '0']]);
const result = resultFor<{
created?: Record<string, { id: string }>;
notCreated?: Record<string, { type?: string; description?: string }>;
}>(responses, '0');
const notCreated = result.notCreated?.[tmpId];
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to add public key');
const created = result.created?.[tmpId];
if (!created?.id) throw new Error('Server did not return the created public key');
return created.id;
}
export async function removePublicKey(id: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([['x:PublicKey/set', { accountId, destroy: [id] }, '0']]);
const result = resultFor<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notDestroyed?.[id];
if (failure) throw new Error(failure.description || failure.type || 'Failed to remove public key');
}
export async function fetchPrincipal(): Promise<PrincipalInfo> {
const accountId = jmapClient.accountId;
const responses = await send([['x:Account/get', { accountId, ids: [accountId] }, '0']]);
const result = resultFor<{
list?: Array<{
description?: string | null;
aliases?: Record<string, { name?: string; enabled?: boolean }>;
quotas?: { maxDiskQuota?: number };
roles?: { ['@type']?: string };
name?: string;
}>;
}>(responses, '0');
const acc = result.list?.[0];
const aliasAddresses = acc?.aliases
? Object.values(acc.aliases).flatMap((a) => (a && a.enabled !== false && a.name ? [a.name] : []))
: [];
const primaryEmail = acc?.name ? [acc.name] : [];
return {
displayName: acc?.description ?? '',
emails: [...primaryEmail, ...aliasAddresses],
quota: acc?.quotas?.maxDiskQuota ?? 0,
roles: acc?.roles?.['@type'] ? [acc.roles['@type']] : [],
};
}
// ── Mutations ─────────────────────────────────────────────
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
[
'x:AccountPassword/set',
{ accountId, update: { singleton: { currentSecret: currentPassword, secret: newPassword } } },
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to change password');
// The stored credential must follow, otherwise the next request 401s and
// the next launch evicts the account.
await jmapClient.updatePassword(newPassword);
}
export async function updateDisplayName(displayName: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
['x:AccountSettings/set', { accountId, update: { singleton: { description: displayName } } }, '0'],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to update display name');
}
export async function enableTotp(currentPassword: string, otpUrl: string, otpCode: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
[
'x:AccountPassword/set',
{ accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl, otpCode } } } },
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to enable two-factor authentication');
}
export async function disableTotp(currentPassword: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
[
'x:AccountPassword/set',
{ accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl: null } } } },
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to disable two-factor authentication');
}
type SetMethod = 'x:AppPassword/set' | 'x:ApiKey/set';
async function createCredential(method: SetMethod, input: AppCredentialInput): Promise<{ id: string; secret: string }> {
const accountId = jmapClient.accountId;
const tmpId = 'new';
const responses = await send([[method, { accountId, create: { [tmpId]: buildCreateBody(input) } }, '0']]);
const result = resultFor<{
created?: Record<string, { id: string; secret: string }>;
notCreated?: Record<string, { type?: string; description?: string }>;
}>(responses, '0');
const notCreated = result.notCreated?.[tmpId];
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to create credential');
const created = result.created?.[tmpId];
if (!created?.id || !created.secret) throw new Error('Server did not return the created credential');
return { id: created.id, secret: created.secret };
}
async function removeCredential(method: SetMethod, id: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([[method, { accountId, destroy: [id] }, '0']]);
const result = resultFor<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notDestroyed?.[id];
if (failure) throw new Error(failure.description || failure.type || 'Failed to remove credential');
}
export function createAppPassword(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
return createCredential('x:AppPassword/set', input);
}
export function removeAppPassword(id: string): Promise<void> {
return removeCredential('x:AppPassword/set', id);
}
export function createApiKey(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
return createCredential('x:ApiKey/set', input);
}
export function removeApiKey(id: string): Promise<void> {
return removeCredential('x:ApiKey/set', id);
}
+173 -31
View File
@@ -1,25 +1,186 @@
import { File } from 'expo-file-system';
import type * as LegacyFileSystemTypes from 'expo-file-system/legacy';
import { jmapClient } from './jmap-client';
import { secureFetch } from '../lib/client-cert';
import { getClientCertAlias, secureFetch } from '../lib/client-cert';
// The legacy expo-file-system API (streaming upload tasks with progress and
// cancellation) is required lazily: it touches native globals at import time,
// which would break every module that merely imports this file in unit tests.
type LegacyFileSystemModule = typeof LegacyFileSystemTypes;
let legacyFileSystem: LegacyFileSystemModule | null = null;
async function loadLegacyFileSystem(): Promise<LegacyFileSystemModule> {
if (!legacyFileSystem) {
legacyFileSystem = (await import('expo-file-system/legacy')) as LegacyFileSystemModule;
}
return legacyFileSystem;
}
export interface UploadBlobOptions {
/** Called with bytes sent / total as the upload streams. */
onProgress?: (sent: number, total: number) => void;
/** Abort the in-flight upload; the promise then rejects with an AbortError. */
signal?: AbortSignal;
}
interface UploadResult {
blobId: string;
size: number;
type: string;
}
function abortError(): Error {
const err = new Error('Upload cancelled');
err.name = 'AbortError';
return err;
}
// JMAP servers return either the direct shape `{blobId, type, size}` or
// the per-account nested shape `{[accountId]: {blobId, type, size}}`.
// Stalwart has shipped both depending on version; mirror the webmail's
// tolerant parsing so uploads don't silently lose the blobId.
function parseUploadResponse(
raw: Record<string, unknown>,
accountId: string,
fallbackSize: number,
fallbackType: string,
): UploadResult {
const direct = raw as { blobId?: string; type?: string; size?: number };
if (typeof direct.blobId === 'string') {
return {
blobId: direct.blobId,
size: typeof direct.size === 'number' ? direct.size : fallbackSize,
type: typeof direct.type === 'string' && direct.type ? direct.type : fallbackType,
};
}
const nested = raw[accountId] as
| { blobId?: string; type?: string; size?: number }
| undefined;
if (nested?.blobId) {
return {
blobId: nested.blobId,
size: typeof nested.size === 'number' ? nested.size : fallbackSize,
type: typeof nested.type === 'string' && nested.type ? nested.type : fallbackType,
};
}
throw new Error('Upload succeeded but response did not include a blobId');
}
function uploadUrlFor(accountId: string): string {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
return session.uploadUrl.replace('{accountId}', encodeURIComponent(accountId));
}
// Upload a local file to the JMAP upload endpoint.
//
// The default path streams the file from disk through expo-file-system's
// native upload task, which reports progress and can be cancelled, so large
// videos/PDFs from the document picker no longer have to fit in JS memory
// (webmail #162/#333). When the user has picked a client certificate the
// native task can't present it, so we fall back to buffering the bytes and
// posting them through the cert-aware fetch.
export async function uploadBlob(
uri: string,
type: string,
): Promise<{ blobId: string; size: number; type: string }> {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
options: UploadBlobOptions = {},
): Promise<UploadResult> {
const accountId = jmapClient.accountId;
const uploadUrl = uploadUrlFor(accountId);
const contentType = type || 'application/octet-stream';
const { onProgress, signal } = options;
if (signal?.aborted) throw abortError();
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(jmapClient.accountId),
const alias = await getClientCertAlias();
if (alias) {
return uploadBlobBuffered(uri, contentType, accountId, uploadUrl, signal);
}
const LegacyFileSystem = await loadLegacyFileSystem();
const task = LegacyFileSystem.createUploadTask(
uploadUrl,
uri,
{
httpMethod: 'POST',
uploadType: LegacyFileSystem.FileSystemUploadType.BINARY_CONTENT,
headers: {
'Content-Type': contentType,
Authorization: jmapClient.authHeader,
},
},
onProgress
? (data) => onProgress(data.totalBytesSent, data.totalBytesExpectedToSend)
: undefined,
);
const onAbort = () => void task.cancelAsync();
signal?.addEventListener('abort', onAbort);
let result: LegacyFileSystemTypes.FileSystemUploadResult | null | undefined;
try {
result = await task.uploadAsync();
} finally {
signal?.removeEventListener('abort', onAbort);
}
if (signal?.aborted || !result) throw abortError();
if (result.status < 200 || result.status >= 300) {
const detail = (result.body || '').slice(0, 300);
throw new Error(`Upload failed: ${result.status}${detail ? ` ${detail}` : ''}`);
}
let raw: Record<string, unknown>;
try {
raw = JSON.parse(result.body) as Record<string, unknown>;
} catch {
throw new Error('Upload succeeded but the server response was not JSON');
}
return parseUploadResponse(raw, accountId, 0, contentType);
}
async function uploadBlobBuffered(
uri: string,
contentType: string,
accountId: string,
uploadUrl: string,
signal?: AbortSignal,
): Promise<UploadResult> {
// Read via the file-system API so this works for both `file://` (image
// picker, iOS document picker) and `content://` (Android SAF) URIs. We pass
// the underlying ArrayBuffer rather than the Uint8Array view because some
// RN transports stringify typed-array bodies, which silently produces an
// empty/garbage upload.
const bytes = await new File(uri).bytes();
if (signal?.aborted) throw abortError();
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': contentType,
Authorization: jmapClient.authHeader,
},
body: bytes.buffer as ArrayBuffer,
signal,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
const raw = (await response.json()) as Record<string, unknown>;
return parseUploadResponse(raw, accountId, bytes.byteLength, contentType);
}
// Direct in-memory upload. The on-disk variant goes through expo-file-system's
// `new File(uri).bytes()`, which is overkill when we already have the bytes
// (e.g. the empty-blob trick used to create directories on Stalwart).
export async function uploadBytes(
bytes: Uint8Array,
type: string,
// Blobs are account-scoped: a message imported into a shared/group account's
// folder has to be uploaded to that account, not the user's own.
accountId?: string,
): Promise<UploadResult> {
const targetAccountId = accountId ?? jmapClient.accountId;
const uploadUrl = uploadUrlFor(targetAccountId);
const response = await secureFetch(uploadUrl, {
method: 'POST',
@@ -35,42 +196,23 @@ export async function uploadBlob(
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
// JMAP servers return either the direct shape `{blobId, type, size}` or
// the per-account nested shape `{[accountId]: {blobId, type, size}}`.
// Stalwart has shipped both depending on version; mirror the webmail's
// tolerant parsing so uploads don't silently lose the blobId.
const raw = (await response.json()) as Record<string, unknown>;
const direct = raw as { blobId?: string; type?: string; size?: number };
if (typeof direct.blobId === 'string') {
return {
blobId: direct.blobId,
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
};
}
const nested = raw[jmapClient.accountId] as
| { blobId?: string; type?: string; size?: number }
| undefined;
if (nested?.blobId) {
return {
blobId: nested.blobId,
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
};
}
throw new Error('Upload succeeded but response did not include a blobId');
return parseUploadResponse(raw, targetAccountId, bytes.byteLength, type);
}
export function getDownloadUrl(
blobId: string,
name?: string,
type?: string,
// Blobs of nodes shared by another principal live in the owner's account;
// downloading them with our own accountId 404s.
accountId?: string,
): string {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
return session.downloadUrl
.replace('{accountId}', encodeURIComponent(jmapClient.accountId))
.replace('{accountId}', encodeURIComponent(accountId ?? jmapClient.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name ?? 'download'))
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
+631 -38
View File
@@ -1,106 +1,699 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Calendar, CalendarEvent } from './types';
import type { Calendar, CalendarEvent, CalendarRights } from './types';
import { getEffectiveTimeZone } from '../lib/calendar-timezone';
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
/**
* IANA time zone of the user — their calendar time-zone setting when set
* (#755), otherwise the device's — sent as the `timeZone` argument on
* CalendarEvent/query and CalendarEvent/get. Stalwart interprets LocalDateTime
* filter values and computes utcStart/utcEnd for floating events in this zone,
* defaulting to UTC when absent — which shifts range boundaries and
* floating-event times for any user not in UTC. Stalwart ignores unparseable
* values, so sending it is always safe.
*/
function getUserTimeZone(): string | undefined {
try {
return getEffectiveTimeZone() || undefined;
} catch {
return undefined;
}
}
/**
* Render an instant as the LocalDateTime ("yyyy-MM-ddTHH:mm:ss") wall-clock
* in `timeZone` — the form CalendarEvent/query's `after`/`before` expect, and
* which Stalwart interprets in the query's `timeZone`. Accepts ISO strings
* (with or without a zone) so callers can pass `Date.toISOString()` output.
*/
export function toLocalDateTime(value: string | Date, timeZone?: string): string {
const date = typeof value === 'string' ? new Date(value) : value;
if (isNaN(date.getTime())) return typeof value === 'string' ? value.replace(/(\.\d+)?Z$/, '') : '';
try {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone,
hourCycle: 'h23',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}).formatToParts(date);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00';
const hour = get('hour') === '24' ? '00' : get('hour');
return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}`;
} catch {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
}
// Shared accounts the server rejected calendar access for — probed once, then
// skipped for the rest of the session (the session lists every account with
// the calendars capability, including ones that only share mail with us).
const calendarAccessDenied = new Set<string>();
export function isCalendarAccessDenied(accountId: string): boolean {
return calendarAccessDenied.has(accountId);
}
export function resetCalendarAccessDenied(): void {
calendarAccessDenied.clear();
}
function isAccessDeniedError(err: unknown): boolean {
const type = (err as { jmapErrorType?: string } | null)?.jmapErrorType;
return type === 'forbidden' || type === 'accountNotFound';
}
/** Remember an access rejection for a shared account; returns true when it was one. */
export function noteCalendarAccessError(accountId: string, err: unknown): boolean {
if (!isAccessDeniedError(err)) return false;
calendarAccessDenied.add(accountId);
return true;
}
/**
* Build a CalendarEvent/query filter restricting results to the given
* calendars. Stalwart implements the singular `inCalendar` condition (one
* calendar id per condition), not the draft's plural `inCalendars` array —
* sending the plural form fails the whole query with `unsupportedFilter`.
* Multiple calendars are expressed as an OR of singular conditions.
*/
function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
if (calendarIds.length === 1) {
return { inCalendar: calendarIds[0] };
}
return {
operator: 'OR',
conditions: calendarIds.map((id) => ({ inCalendar: id })),
};
}
// Stalwart's calcard crate implements JSCalendar 2.0 (jscalendarbis) property
// names: singular `recurrenceRule` / `excludedRecurrenceRule` holding a single
// object, not RFC 8984's plural array forms. Requesting the plural names
// returns no recurrence data at all, so recurring events silently render as
// one-off events (#13). Request the singular names and normalize below.
const CALENDAR_EVENT_PROPERTIES = [
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
'start', 'duration', 'timeZone', 'showWithoutTime',
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
'participants', 'alerts', 'recurrenceRules',
'recurrenceOverrides', 'excludedRecurrenceRules',
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule',
'recurrenceOverrides', 'excludedRecurrenceRule', 'recurrenceId',
'replyTo', 'organizerCalendarAddress', 'sequence',
'locations', 'virtualLocations',
'progress', 'due', 'priority', 'percentComplete',
'links', 'created', 'updated',
];
/**
* Normalize Stalwart's singular recurrence property names to the RFC 8984
* plural array forms the client uses internally. JSCalendar 2.0 defines
* recurrenceRule as a single object, but Stalwart may also return an array
* (for events created via JMAP), so both forms are handled. Mirrors webmail's
* normalizeStalwartPropertyNames.
*/
export function normalizeRecurrenceProperties<T extends Partial<CalendarEvent>>(event: T): T {
const raw = event as Record<string, unknown>;
let patched = false;
const updates: Partial<CalendarEvent> = {};
if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) {
const val = raw.recurrenceRule;
if (val != null && !Array.isArray(val) && typeof val === 'object') {
updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules'];
} else {
updates.recurrenceRules = val as CalendarEvent['recurrenceRules'];
}
patched = true;
}
if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) {
const val = raw.excludedRecurrenceRule;
if (val != null && !Array.isArray(val) && typeof val === 'object') {
updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules'];
} else {
updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules'];
}
patched = true;
}
if (!patched) return event;
const result = { ...event, ...updates } as T;
delete (result as Record<string, unknown>).recurrenceRule;
delete (result as Record<string, unknown>).excludedRecurrenceRule;
return result;
}
/**
* Convert the client's plural recurrence arrays to the singular JSCalendar 2.0
* properties Stalwart expects on write, dropping null rule fields. An empty
* array (or null) becomes an explicit null so "remove recurrence" round-trips.
* Mirrors webmail's cleanRecurrenceRules.
*/
function cleanRecurrenceRules(event: Record<string, unknown>): void {
const keyMap: Record<string, string> = {
recurrenceRules: 'recurrenceRule',
excludedRecurrenceRules: 'excludedRecurrenceRule',
};
for (const [pluralKey, singularKey] of Object.entries(keyMap)) {
const rules = event[pluralKey];
if (rules === undefined) continue;
delete event[pluralKey];
if (!Array.isArray(rules)) {
event[singularKey] = rules;
continue;
}
if (rules.length === 0) {
event[singularKey] = null;
continue;
}
const rule = rules[0] as Record<string, unknown>;
const cleaned: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rule)) {
if (v !== null) cleaned[k] = v;
}
event[singularKey] = cleaned;
}
}
function methodResult<T = any>(res: any, index = 0): T {
const entry = res?.methodResponses?.[index];
if (!entry) throw new Error('JMAP: empty method response');
if (entry[0] === 'error') {
const err = entry[1] || {};
throw new Error(err.description || err.type || 'JMAP method error');
// Keep the JMAP error type so callers can tell an expected access
// rejection (forbidden / accountNotFound on a shared account) apart from
// a genuine failure.
throw Object.assign(
new Error(err.description || err.type || 'JMAP method error'),
{ jmapErrorType: err.type as string | undefined },
);
}
return entry[1] as T;
}
// Other session accounts that expose the calendars capability hold calendars
// shared with the user (JMAP surfaces shared data under the sharer's account).
function sharedCalendarAccountIds(): string[] {
const session = jmapClient.currentSession;
const primary = jmapClient.accountId;
return Object.entries(session?.accounts ?? {})
.filter(([id, info]) =>
id !== primary && !!info.accountCapabilities?.[CAPABILITIES.CALENDARS])
.map(([id]) => id);
}
export async function getCalendars(): Promise<Calendar[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/get', { accountId }, '0']],
USING,
);
return methodResult<{ list: Calendar[] }>(res).list ?? [];
}
const own = methodResult<{ list: Calendar[] }>(res).list ?? [];
export async function queryEvents(
_calendarIds: string[],
_after: string,
_before: string,
): Promise<string[]> {
// Stalwart rejects inCalendars/after/before filters on CalendarEvent/query.
// Fetch all events in the account; calendar visibility + date filtering is
// done client-side.
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/query', { accountId, limit: 1000 }, '0']],
USING,
// Calendars shared with the user live in other session accounts. Failures
// there (revoked share, transient error) must not hide the user's own
// calendars, so each shared account is fetched best-effort.
const shared = await Promise.all(
sharedCalendarAccountIds().map(async (sharedAccountId) => {
// Don't re-probe an account the server already rejected this session.
if (calendarAccessDenied.has(sharedAccountId)) return [];
try {
const sharedRes = await jmapClient.request(
[['Calendar/get', { accountId: sharedAccountId }, '0']],
USING,
);
const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? [];
// JMAP calendar ids are only unique within an account, so a shared
// "default" calendar collides with the user's own "default". Namespace
// the id (keeping the real id in originalId) so visibility toggles,
// event-query routing, and create/default mutations don't cross wires.
// Mirrors webmail's getAllCalendars.
return list.map((cal) => ({
...cal,
id: `${sharedAccountId}:${cal.id}`,
originalId: cal.id,
accountId: sharedAccountId,
isShared: true,
}));
} catch (err) {
noteCalendarAccessError(sharedAccountId, err);
return [];
}
}),
);
return methodResult<{ ids: string[] }>(res).ids ?? [];
return [...own, ...shared.flat()];
}
export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
const QUERY_PAGE_SIZE = 1000;
// Backstop against a server that keeps returning full pages; 20 pages is far
// beyond any real calendar window and keeps a bug from looping forever.
const QUERY_MAX_PAGES = 20;
/**
* Query event ids in a date window. `after`/`before` are instants (ISO, as
* from `Date.toISOString()`) or LocalDateTime strings; they're sent as
* LocalDateTime in the query's `timeZone` so Stalwart interprets the bounds
* in the user's zone. Calendars are restricted via the singular `inCalendar`
* condition (the plural `inCalendars` fails the whole query with
* unsupportedFilter on Stalwart); the range and calendar conditions are
* combined with an AND operator. Without bounds every object is returned.
* The result set is paged: a window can hold more events than one page, and
* a truncated page must never pass for a complete answer.
*/
export async function queryEvents(
calendarIds: string[],
after: string,
before: string,
accountId?: string,
): Promise<string[]> {
const account = accountId || jmapClient.accountId;
const timeZone = getUserTimeZone();
const conditions: Record<string, unknown>[] = [];
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
const range: Record<string, string> = {};
if (after) range.after = toLocalDateTime(after, timeZone);
if (before) range.before = toLocalDateTime(before, timeZone);
if (Object.keys(range).length > 0) conditions.push(range);
const filter = conditions.length === 1
? conditions[0]
: conditions.length > 1
? { operator: 'AND', conditions }
: undefined;
const ids: string[] = [];
for (let page = 0; page < QUERY_MAX_PAGES; page++) {
const args: Record<string, unknown> = {
accountId: account,
limit: QUERY_PAGE_SIZE,
position: ids.length,
};
if (timeZone) args.timeZone = timeZone;
if (filter) args.filter = filter;
const res = await jmapClient.request(
[['CalendarEvent/query', args, '0']],
USING,
);
const batch = methodResult<{ ids: string[] }>(res).ids ?? [];
ids.push(...batch);
if (batch.length < QUERY_PAGE_SIZE) break;
}
return ids;
}
/**
* Every calendar object of an account with just the properties needed to tell
* tasks from events (see lib/calendar-component-detection). Used to find
* tasks-only calendars and the task ids; pages through the query so accounts
* with more than 1000 objects are covered.
*/
export async function scanCalendarObjects(accountId?: string): Promise<ScannedCalendarObject[]> {
const account = accountId || jmapClient.accountId;
const pageSize = 1000;
const ids: string[] = [];
for (let position = 0; ; position += pageSize) {
const res = await jmapClient.request(
[['CalendarEvent/query', { accountId: account, position, limit: pageSize }, '0']],
USING,
);
const page = methodResult<{ ids: string[] }>(res).ids ?? [];
ids.push(...page);
if (page.length < pageSize) break;
}
if (ids.length === 0) return [];
const accountId = jmapClient.accountId;
const batchSize = jmapClient.getMaxObjectsInGet();
const all: ScannedCalendarObject[] = [];
for (let i = 0; i < ids.length; i += batchSize) {
const res = await jmapClient.request(
[['CalendarEvent/get', {
accountId: account,
ids: ids.slice(i, i + batchSize),
properties: SCAN_PROPERTIES,
}, '0']],
USING,
);
all.push(...(methodResult<{ list: ScannedCalendarObject[] }>(res).list ?? []));
}
return all;
}
export async function getEvents(ids: string[], accountId?: string): Promise<CalendarEvent[]> {
if (ids.length === 0) return [];
const account = accountId || jmapClient.accountId;
const timeZone = getUserTimeZone();
const batchSize = jmapClient.getMaxObjectsInGet();
const all: CalendarEvent[] = [];
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
const res = await jmapClient.request(
[['CalendarEvent/get', {
accountId,
accountId: account,
ids: batch,
properties: CALENDAR_EVENT_PROPERTIES,
...(timeZone ? { timeZone } : {}),
}, '0']],
USING,
);
const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? [];
all.push(...list);
all.push(...list.map(normalizeRecurrenceProperties));
}
return all;
}
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
// / reply / cancellation emails to participants. We pass it whenever an event
// has participants so creating/updating/deleting a meeting notifies attendees.
function setArgs(
accountId: string,
payload: Record<string, unknown>,
sendSchedulingMessages?: boolean,
): Record<string, unknown> {
const args: Record<string, unknown> = { accountId, ...payload };
if (sendSchedulingMessages !== undefined) {
args.sendSchedulingMessages = sendSchedulingMessages;
}
return args;
}
export async function createEvent(
event: Partial<CalendarEvent>,
calendarId: string,
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<CalendarEvent> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
const payload: Record<string, unknown> = { ...event, calendarIds: { [calendarId]: true } };
cleanRecurrenceRules(payload);
const res = await jmapClient.request(
[['CalendarEvent/set', {
accountId,
create: {
'new-event': { ...event, calendarIds: { [calendarId]: true } },
},
}, '0']],
[['CalendarEvent/set', setArgs(accountId, {
create: { 'new-event': payload },
}, sendSchedulingMessages), '0']],
USING,
);
return res.methodResponses[0][1].created['new-event'];
const result = methodResult<{
created?: Record<string, CalendarEvent>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(res);
const created = result.created?.['new-event'];
if (!created) {
const err = result.notCreated?.['new-event'];
throw new Error(err?.description || err?.type || 'Failed to create event');
}
// The /set response echoes only server-set properties; merge them over the
// submitted payload so the store's optimistic insert keeps recurrence data.
return normalizeRecurrenceProperties({ ...payload, ...created } as CalendarEvent);
}
// Batch-create many events in one CalendarEvent/set. Returns the number created.
export async function batchCreateEvents(
events: Partial<CalendarEvent>[],
calendarId: string,
targetAccountId?: string,
): Promise<number> {
if (events.length === 0) return 0;
const accountId = targetAccountId || jmapClient.accountId;
const create: Record<string, Partial<CalendarEvent>> = {};
events.forEach((e, i) => {
const payload: Record<string, unknown> = { ...e, calendarIds: { [calendarId]: true } };
cleanRecurrenceRules(payload);
create[`evt-${i}`] = payload as Partial<CalendarEvent>;
});
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, create }, '0']],
USING,
);
const result = methodResult<{ created?: Record<string, unknown> }>(res);
return result.created ? Object.keys(result.created).length : 0;
}
export async function updateEvent(
id: string,
changes: Partial<CalendarEvent>,
changes: Partial<CalendarEvent> | Record<string, unknown>,
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
const patch: Record<string, unknown> = { ...changes };
cleanRecurrenceRules(patch);
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to update event');
}
export async function deleteEvents(
ids: string[],
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', { accountId, update: { [id]: changes } }, '0']],
[['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']],
USING,
);
}
export async function deleteEvents(ids: string[]): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', { accountId, destroy: ids }, '0']],
// RSVP to an invitation: patch the participant's participationStatus via a JSON
// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages).
// Stalwart routes the REPLY to the stored ORGANIZER (organizerCalendarAddress);
// the RFC 8984 replyTo property is retired in jscalendarbis and ignored. The
// caller passes `repairOrganizerAddress` only for events that lack an
// organizer (e.g. imported ones) — attendees may not modify an existing one.
export async function rsvpEvent(
eventId: string,
participantId: string,
status: 'accepted' | 'declined' | 'tentative',
repairOrganizerAddress?: string | null,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
// Escape per RFC 6901: ~ → ~0, / → ~1.
const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patch: Record<string, unknown> = {
[`participants/${escaped}/participationStatus`]: status,
};
if (repairOrganizerAddress) patch.organizerCalendarAddress = repairOrganizerAddress;
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[eventId];
if (err) throw new Error(err.description || err.type || 'Failed to send RSVP');
}
// Parse an uploaded .ics blob into one or more JSCalendar events (server-side).
// `targetAccountId` routes the parse to the account that owns the blob (an
// email in a shared mailbox lives in the sharer's account).
export async function parseCalendarBlob(
blobId: string,
targetAccountId?: string,
): Promise<Partial<CalendarEvent>[]> {
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']],
USING,
);
const result = methodResult<{
parsed?: Record<string, CalendarEvent | CalendarEvent[]>;
notParsable?: string[];
notFound?: string[];
}>(res);
if (result.notParsable?.includes(blobId)) throw new Error('Invalid calendar file format');
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
const parsed = result.parsed?.[blobId];
if (!parsed) return [];
const list = Array.isArray(parsed) ? parsed : [parsed];
return list.map(normalizeRecurrenceProperties);
}
// Download the raw text of a calendar blob (the .ics itself) so the iTIP
// METHOD line can be read — JMAP strips the `method=` Content-Type parameter.
// Best-effort: returns null when the download fails.
export async function fetchCalendarBlobText(
blobId: string,
targetAccountId?: string,
): Promise<string | null> {
try {
await jmapClient.ensureFreshToken();
// Lazy: blob/client-cert pull in native Expo modules, which the pure
// JMAP layer (and its node tests) must not load eagerly.
const [{ getDownloadUrl }, { secureFetch }] = await Promise.all([
import('./blob'),
import('../lib/client-cert'),
]);
const url = getDownloadUrl(blobId, 'invite.ics', 'text/calendar', targetAccountId);
const res = await secureFetch(url, { headers: { Authorization: jmapClient.authHeader } });
if (!res.ok) return null;
const text = await res.text();
return text.length > 2 * 1024 * 1024 ? null : text;
} catch {
return null;
}
}
export async function createCalendar(
name: string,
color?: string,
description?: string,
): Promise<Calendar> {
const accountId = jmapClient.accountId;
const props: Record<string, unknown> = { name, color, isVisible: true, isSubscribed: true };
if (description?.trim()) props.description = description.trim();
const res = await jmapClient.request(
[['Calendar/set', {
accountId,
create: { 'new-cal': props },
}, '0']],
USING,
);
const result = methodResult<{
created?: Record<string, Calendar>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(res);
const created = result.created?.['new-cal'];
if (!created) {
const err = result.notCreated?.['new-cal'];
throw new Error(err?.description || err?.type || 'Failed to create calendar');
}
return created;
}
/**
* Mark a calendar as the account default. `isDefault` is read-only in
* Stalwart's Calendar/set — the default is changed via the
* `onSuccessSetIsDefault` request argument instead.
*/
export async function setDefaultCalendar(
calendarId: string,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', { accountId, onSuccessSetIsDefault: calendarId }, '0']],
USING,
);
methodResult(res);
}
// Destroy a calendar. `onDestroyRemoveEvents` removes its events too —
// Stalwart otherwise refuses to delete a non-empty calendar.
export async function deleteCalendar(id: string, targetAccountId?: string): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']],
USING,
);
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notDestroyed?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to delete calendar');
}
export interface CalendarUpdates {
name?: string;
color?: string | null;
description?: string | null;
sortOrder?: number;
isSubscribed?: boolean;
}
// Rename / recolour / describe a calendar (Calendar/set update).
export async function updateCalendar(
id: string,
updates: CalendarUpdates,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', { accountId, update: { [id]: updates } }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to update calendar');
}
// Add, update, or remove a principal's rights on an owned calendar via a
// `shareWith/{principalId}` patch (null removes the share).
export async function setCalendarShare(
calendarId: string,
principalId: string,
rights: CalendarRights | null,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', {
accountId,
update: { [calendarId]: { [`shareWith/${principalId}`]: rights } },
}, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[calendarId];
if (err) throw new Error(err.description || err.type || 'Failed to update sharing');
}
/**
* Remove every event from a calendar. Events that live ONLY in this calendar
* are destroyed; events also linked to another calendar are unlinked instead
* (their `calendarIds` loses this calendar) so the user's copy elsewhere is
* not cascade-deleted. Returns the number of events removed.
*/
export async function clearCalendarEvents(
calendarId: string,
targetAccountId?: string,
): Promise<number> {
const accountId = targetAccountId || jmapClient.accountId;
let removed = 0;
// Loop for accounts with more than one query page of objects.
for (let pass = 0; pass < 20; pass++) {
const ids = await queryEvents([calendarId], '', '', accountId);
if (ids.length === 0) break;
const batchSize = jmapClient.getMaxObjectsInGet();
const objects: Array<{ id: string; calendarIds?: Record<string, boolean> }> = [];
for (let i = 0; i < ids.length; i += batchSize) {
const res = await jmapClient.request(
[['CalendarEvent/get', {
accountId,
ids: ids.slice(i, i + batchSize),
properties: ['id', 'calendarIds'],
}, '0']],
USING,
);
objects.push(...(methodResult<{ list: typeof objects }>(res).list ?? []));
}
const toDestroy: string[] = [];
const toUnlink: Array<{ id: string; calendarIds: Record<string, boolean> }> = [];
for (const obj of objects) {
const others = { ...(obj.calendarIds || {}) };
delete others[calendarId];
if (Object.keys(others).length === 0) toDestroy.push(obj.id);
else toUnlink.push({ id: obj.id, calendarIds: others });
}
if (toDestroy.length > 0) {
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, destroy: toDestroy }, '0']],
USING,
);
removed += (methodResult<{ destroyed?: string[] }>(res).destroyed ?? []).length;
}
if (toUnlink.length > 0) {
const update: Record<string, unknown> = {};
for (const { id, calendarIds } of toUnlink) update[id] = { calendarIds };
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, update }, '0']],
USING,
);
removed += Object.keys(methodResult<{ updated?: Record<string, unknown> }>(res).updated ?? {}).length;
}
if (toDestroy.length === 0 && toUnlink.length === 0) break;
if (ids.length < 1000) break;
}
return removed;
}
+315 -31
View File
@@ -1,9 +1,25 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { ContactCard, AddressBook } from './types';
import { generateUUID } from '../lib/uuid';
const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS];
// Stalwart's default property list for AddressBook/get omits shareWith, so
// existing shares would be invisible after a fresh login (webmail #257).
const ADDRESS_BOOK_PROPERTIES = [
'id',
'name',
'description',
'sortOrder',
'isDefault',
'isSubscribed',
'shareWith',
'myRights',
] as const;
type SetError = { description?: string; type?: string; properties?: string[] };
function methodResult<T = any>(res: any, index = 0): T {
const entry = res?.methodResponses?.[index];
if (!entry) throw new Error('JMAP: empty method response');
@@ -14,40 +30,139 @@ function methodResult<T = any>(res: any, index = 0): T {
return entry[1] as T;
}
export async function getAddressBooks(): Promise<AddressBook[]> {
const accountId = jmapClient.accountId;
function setErrorMessage(err: SetError | undefined, fallback: string): string {
if (!err) return fallback;
const detail = err.description || err.type || fallback;
return err.properties?.length ? `${detail} (${err.properties.join(', ')})` : detail;
}
/**
* Primary account for the contacts capability (RFC 8620 `primaryAccounts`),
* falling back to the mail primary. Contacts can live in a different account
* than mail on some servers.
*/
export function getContactsAccountId(): string {
const id = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CONTACTS];
return id || jmapClient.accountId;
}
/**
* Every account that may hold address books: the contacts primary first, then
* accounts that advertise the contacts capability or are non-personal (shared
* / group) accounts - Stalwart doesn't always advertise capabilities on group
* accounts even when they have contact resources. Mirrors the webmail's
* getContactCapableAccountIds().
*/
export function getContactCapableAccountIds(): string[] {
const primaryId = getContactsAccountId();
const accounts = jmapClient.currentSession?.accounts ?? {};
const out: string[] = [];
for (const [id, account] of Object.entries(accounts)) {
if (id === primaryId) continue;
if (account.accountCapabilities?.[CAPABILITIES.CONTACTS] || account.isPersonal === false) {
out.push(id);
}
}
return [primaryId, ...out];
}
function accountName(accountId: string): string {
return jmapClient.currentSession?.accounts?.[accountId]?.name || accountId;
}
/** Namespace an id from a non-primary account as `<accountId>:<id>`. */
export function namespaceId(accountId: string, id: string): string {
return `${accountId}:${id}`;
}
/**
* Strip client-only metadata before a card goes to the server. Shared-account
* cards carry namespaced ids and account tags that JMAP would reject as
* unknown properties.
*/
export function stripClientFields<T extends Partial<ContactCard>>(contact: T): Partial<ContactCard> {
const {
originalId: _oid, accountId: _aid, accountName: _an, isShared: _is,
...rest
} = contact as ContactCard;
return rest;
}
export async function getAddressBooks(accountId?: string): Promise<AddressBook[]> {
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['AddressBook/get', { accountId }, '0']],
[['AddressBook/get', { accountId: account, properties: ADDRESS_BOOK_PROPERTIES }, '0']],
USING,
);
return methodResult<{ list: AddressBook[] }>(res).list ?? [];
}
export async function queryContacts(
filter?: { text?: string; inAddressBook?: string },
limit = 1000,
): Promise<string[]> {
// Match webmail: no `sort` (unsupported by Stalwart for ContactCard/query).
// Client sorts by display name after fetch.
const accountId = jmapClient.accountId;
const args: Record<string, unknown> = { accountId, limit };
if (filter && Object.keys(filter).length > 0) args.filter = filter;
const res = await jmapClient.request(
[['ContactCard/query', args, '0']],
USING,
);
return methodResult<{ ids: string[] }>(res).ids ?? [];
/**
* Address books across every contacts-capable account. Books from non-primary
* (shared / group) accounts get a namespaced id plus `accountId`, `accountName`
* and `isShared`; a failing shared account never hides the user's own books.
*/
export async function getAllAddressBooks(): Promise<AddressBook[]> {
const primaryId = getContactsAccountId();
const all: AddressBook[] = [];
for (const accountId of getContactCapableAccountIds()) {
const isPrimary = accountId === primaryId;
try {
const books = await getAddressBooks(accountId);
all.push(...books.map((book) => ({
...book,
id: isPrimary ? book.id : namespaceId(accountId, book.id),
originalId: book.id,
accountId,
accountName: isPrimary ? (jmapClient.username || accountName(accountId)) : accountName(accountId),
isShared: !isPrimary,
})));
} catch (err) {
if (isPrimary) throw err;
console.warn(`[contacts] address books for shared account ${accountId} failed`, err);
}
}
return all;
}
export async function getContacts(ids: string[]): Promise<ContactCard[]> {
export async function queryContacts(
filter?: { text?: string; inAddressBook?: string },
limit?: number,
accountId?: string,
): Promise<string[]> {
// Match webmail: no `sort` (unsupported by Stalwart for ContactCard/query).
// Client sorts by display name after fetch. The server may clamp `limit`
// below what we ask for, so walk `position` until the total is reached.
const account = accountId || getContactsAccountId();
const batchSize = limit ?? jmapClient.getMaxObjectsInGet();
const allIds: string[] = [];
let position = 0;
for (;;) {
const args: Record<string, unknown> = { accountId: account, position, limit: batchSize };
if (filter && Object.keys(filter).length > 0) args.filter = filter;
const res = await jmapClient.request(
[['ContactCard/query', args, '0']],
USING,
);
const result = methodResult<{ ids?: string[]; total?: number }>(res);
const ids = result.ids ?? [];
allIds.push(...ids);
const total = typeof result.total === 'number' ? result.total : -1;
if (ids.length === 0 || ids.length < batchSize || (total >= 0 && allIds.length >= total)) break;
position += ids.length;
}
return allIds;
}
export async function getContacts(ids: string[], accountId?: string): Promise<ContactCard[]> {
if (ids.length === 0) return [];
const accountId = jmapClient.accountId;
const account = accountId || getContactsAccountId();
const batchSize = jmapClient.getMaxObjectsInGet();
const all: ContactCard[] = [];
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
const res = await jmapClient.request(
[['ContactCard/get', { accountId, ids: batch }, '0']],
[['ContactCard/get', { accountId: account, ids: batch }, '0']],
USING,
);
const list = methodResult<{ list: ContactCard[] }>(res).list ?? [];
@@ -56,38 +171,207 @@ export async function getContacts(ids: string[]): Promise<ContactCard[]> {
return all;
}
/** Tag a card fetched from `accountId` with the client-side account metadata. */
function tagContact(contact: ContactCard, accountId: string, isPrimary: boolean): ContactCard {
if (isPrimary) return contact;
const addressBookIds = contact.addressBookIds
? Object.fromEntries(
Object.entries(contact.addressBookIds).map(([bookId, v]) => [namespaceId(accountId, bookId), v]),
)
: contact.addressBookIds;
return {
...contact,
id: namespaceId(accountId, contact.id),
originalId: contact.id,
addressBookIds,
accountId,
accountName: accountName(accountId),
isShared: true,
};
}
/**
* Contact cards across every contacts-capable account, namespaced like
* getAllAddressBooks. A failing shared account is skipped; a failing primary
* account throws so the store can surface the error.
*/
export async function getAllContacts(): Promise<ContactCard[]> {
const primaryId = getContactsAccountId();
const all: ContactCard[] = [];
for (const accountId of getContactCapableAccountIds()) {
const isPrimary = accountId === primaryId;
try {
const ids = await queryContacts(undefined, undefined, accountId);
const cards = await getContacts(ids, accountId);
all.push(...cards.map((card) => tagContact(card, accountId, isPrimary)));
} catch (err) {
if (isPrimary) throw err;
console.warn(`[contacts] contacts for shared account ${accountId} failed`, err);
}
}
return all;
}
export async function getContact(id: string, accountId?: string): Promise<ContactCard | null> {
const list = await getContacts([id], accountId);
return list[0] ?? null;
}
export async function createContact(
contact: Partial<ContactCard>,
addressBookId: string,
accountId?: string,
): Promise<ContactCard> {
const accountId = jmapClient.accountId;
const account = accountId || getContactsAccountId();
const data = stripClientFields(contact);
const res = await jmapClient.request(
[['ContactCard/set', {
accountId,
accountId: account,
create: {
'new-contact': { ...contact, addressBookIds: { [addressBookId]: true } },
'new-contact': {
...data,
// Stalwart stores the card without one if omitted (#644), which
// breaks group membership and CardDAV round-trips.
uid: data.uid || `urn:uuid:${generateUUID()}`,
addressBookIds: { [addressBookId]: true },
},
},
}, '0']],
USING,
);
return res.methodResponses[0][1].created['new-contact'];
const result = methodResult<{
created?: Record<string, Partial<ContactCard>>;
notCreated?: Record<string, SetError>;
}>(res);
const created = result.created?.['new-contact'];
if (!created?.id) {
throw new Error(setErrorMessage(result.notCreated?.['new-contact'], 'Failed to create contact'));
}
// The set response only carries server-set properties (id, created,
// updated...). Re-fetch the full card so the store appends a complete row
// instead of an "Unnamed" stub; fall back to a client-side merge.
try {
const full = await getContact(created.id, account);
if (full) return full;
} catch {
// fall through to the merge below
}
return {
...data,
uid: data.uid,
...created,
id: created.id,
addressBookIds: { [addressBookId]: true },
} as ContactCard;
}
export async function updateContact(
id: string,
changes: Partial<ContactCard>,
accountId?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['ContactCard/set', { accountId, update: { [id]: changes } }, '0']],
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['ContactCard/set', { accountId: account, update: { [id]: stripClientFields(changes) } }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, SetError> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err, 'Failed to update contact'));
}
export async function deleteContacts(ids: string[]): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['ContactCard/set', { accountId, destroy: ids }, '0']],
/**
* Destroy cards. Returns the ids the server actually destroyed; throws when
* nothing could be destroyed (with the first server description) so a
* single-card delete surfaces the reason.
*/
export async function deleteContacts(ids: string[], accountId?: string): Promise<string[]> {
if (ids.length === 0) return [];
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['ContactCard/set', { accountId: account, destroy: ids }, '0']],
USING,
);
const result = methodResult<{
destroyed?: string[];
notDestroyed?: Record<string, SetError>;
}>(res);
const failed = result.notDestroyed ?? {};
// Servers that omit `destroyed` still honoured everything not listed as failed.
const destroyed = result.destroyed ?? ids.filter((id) => !(id in failed));
if (destroyed.length === 0) {
const firstFailure = Object.values(failed)[0];
throw new Error(setErrorMessage(firstFailure, 'Failed to delete contact'));
}
return destroyed;
}
export async function createAddressBook(name: string, accountId?: string): Promise<AddressBook> {
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['AddressBook/set', { accountId: account, create: { 'new-book': { name } } }, '0']],
USING,
);
const result = methodResult<{
created?: Record<string, AddressBook>;
notCreated?: Record<string, SetError>;
}>(res);
const created = result.created?.['new-book'];
if (created) return { ...created, name };
throw new Error(setErrorMessage(result.notCreated?.['new-book'], 'Failed to create address book'));
}
export async function updateAddressBook(
id: string,
updates: Partial<AddressBook>,
accountId?: string,
): Promise<void> {
const account = accountId || getContactsAccountId();
// Only forward server-settable properties. `isDefault` is deliberately not
// among them: it is read-only in AddressBook/set and including it fails the
// whole update with invalidProperties - use setDefaultAddressBook instead.
const { name, description, sortOrder } = updates as Record<string, unknown>;
const patch: Record<string, unknown> = {};
if (name !== undefined) patch.name = name;
if (description !== undefined) patch.description = description;
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
const res = await jmapClient.request(
[['AddressBook/set', { accountId: account, update: { [id]: patch } }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, SetError> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err, 'Failed to update address book'));
}
/**
* Mark an address book as the account default. `isDefault` is read-only in
* AddressBook/set - the default is changed via the `onSuccessSetIsDefault`
* request argument instead (same shape as Calendar/set).
*/
export async function setDefaultAddressBook(id: string, accountId?: string): Promise<void> {
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['AddressBook/set', { accountId: account, onSuccessSetIsDefault: id }, '0']],
USING,
);
methodResult(res);
}
export async function deleteAddressBook(id: string, accountId?: string): Promise<void> {
const account = accountId || getContactsAccountId();
const res = await jmapClient.request(
[['AddressBook/set', { accountId: account, destroy: [id] }, '0']],
USING,
);
const result = methodResult<{ notDestroyed?: Record<string, SetError> }>(res);
const err = result.notDestroyed?.[id];
if (err) throw new Error(setErrorMessage(err, 'Failed to delete address book'));
}
/** Fetch all contact cards that live in a specific address book. */
export async function getContactsInBook(addressBookId: string, accountId?: string): Promise<ContactCard[]> {
const ids = await queryContacts({ inAddressBook: addressBookId }, undefined, accountId);
return getContacts(ids, accountId);
}
+1202 -190
View File
File diff suppressed because it is too large Load Diff
+315 -55
View File
@@ -1,88 +1,275 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
import { getDownloadUrl, uploadBlob } from './blob';
import type { FileNode, FileNodeRights, JMAPAccountInfo, Principal } from './types';
import { getDownloadUrl, uploadBlob, type UploadBlobOptions } from './blob';
import { decodeFileNodeName } from '../lib/filenode-name';
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
// mark a directory; everything else is a MIME type.
const DIRECTORY_TYPES = new Set([
'd',
'application/x-directory',
'text/directory',
'httpd/unix-directory',
'inode/directory',
]);
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
return DIRECTORY_TYPES.has(node.type) || node.type.includes('directory');
// A FileNode is a folder (container) only when it has no blob content — the
// server stores it with `file == null`. Sending a blobId/type/size on create
// (as older builds did: type 'd' + an empty blob) makes a 0-byte FILE that
// nothing can ever be parented under (#379). The webmail migrates those
// legacy flat-named nodes into the real parentId hierarchy on load; this app
// reads and writes only the real hierarchy.
export function isFolder(node: Pick<FileNode, 'blobId'>): boolean {
return node.blobId == null;
}
// Stalwart's FileNode/get does not support a parentId filter — fetching with
// `ids: null` returns the whole tree, which the caller filters client-side.
// Mirrors the webmail file-store; keeping the API uniform avoids a divergence
// later when we add other JMAP servers.
export async function getAllFileNodes(): Promise<FileNode[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null }, '0']],
USING,
// Stalwart omits shareWith/myRights from FileNode/get unless they are
// requested explicitly, so the share sheet and indicators must name them here.
// The modification timestamp is `modified` (RFC draft-ietf-jmap-filenode);
// asking for a property the server doesn't know silently yields undefined,
// which is how "updated" left every date blank (#700).
const FILE_NODE_PROPERTIES = [
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified',
'shareWith', 'myRights',
];
// RFC 9670 sharing is only usable when the server advertises the exact
// `principals:owner` capability, either in the session or on the account
// (Stalwart places it in accountCapabilities).
export function supportsSharing(): boolean {
return (
jmapClient.hasCapability(CAPABILITIES.PRINCIPALS_OWNER) ||
jmapClient.hasAccountCapability(CAPABILITIES.PRINCIPALS_OWNER, filesAccountId())
);
return (res.methodResponses[0][1].list ?? []) as FileNode[];
}
export async function createFileNode(
fileNode: Partial<FileNode>,
): Promise<FileNode> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
create: { 'new-file': fileNode },
}, '0']],
USING,
export function filesAccountId(): string {
return (
jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.FILES] ??
jmapClient.accountId
);
const created = res.methodResponses[0][1].created?.['new-file'];
if (!created) {
const err = res.methodResponses[0][1].notCreated?.['new-file'];
throw new Error(err?.description || 'Create failed');
}
// Gate on the ACCOUNT capability, not only the server-wide session
// capability. A server can advertise urn:ietf:params:jmap:filenode while a
// specific account has its jmap-file-node-* permissions revoked, in which case
// the capability is absent from that account's accountCapabilities and every
// FileNode action fails with an authorization error (#563). Non-personal
// (shared/group) accounts don't always advertise per-account, so treat those
// as capable. Mirrors the webmail's `supportsFiles`.
export function accountSupportsFiles(
account: JMAPAccountInfo | undefined,
sessionCapabilities: Record<string, unknown> | undefined,
): boolean {
if (!sessionCapabilities || !(CAPABILITIES.FILES in sessionCapabilities)) return false;
if (!account) return false;
return account.accountCapabilities?.[CAPABILITIES.FILES] != null || !account.isPersonal;
}
export function supportsFiles(): boolean {
const session = jmapClient.currentSession;
if (!session) return false;
return accountSupportsFiles(session.accounts?.[filesAccountId()], session.capabilities);
}
/** Server-advertised upload ceiling in bytes (0 = unknown / unlimited). */
export function getMaxSizeUpload(): number {
return jmapClient.getMaxSizeUpload();
}
function fileUsing(): string[] {
const using: string[] = [CAPABILITIES.CORE];
if (jmapClient.hasCapability(CAPABILITIES.FILES)) {
using.push(CAPABILITIES.FILES);
}
return created;
// Required for shareWith/myRights on FileNode and for cross-account
// (shared-with-me) FileNode/get.
if (supportsSharing()) {
using.push(CAPABILITIES.PRINCIPALS_OWNER);
}
return using;
}
// Nodes fetched from another principal's account are namespaced
// "accountId:nodeId" so they can't collide with the primary account's ids.
// JMAP ids never contain ':', so the separator unambiguously marks a shared
// (cross-account) node.
export function isCrossAccountId(id: string | null | undefined): boolean {
return id != null && id.includes(':');
}
// Fetch every FileNode in the account, files AND folders, to build the
// hierarchy client-side from parentId links.
//
// IMPORTANT: this uses FileNode/get with ids:null (return-all), NOT
// FileNode/query — Stalwart's query only returns leaf files and omits folder
// nodes entirely, which would make every folder invisible.
export async function getAllFileNodes(): Promise<FileNode[]> {
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
fileUsing(),
);
const result = res.methodResponses[0];
if (!result || result[0] === 'error') {
throw new Error(result?.[1]?.description || 'FileNode list failed');
}
return ((result[1].list ?? []) as FileNode[]).map((node) => ({
...node,
name: decodeFileNodeName(node.name),
}));
}
// Accounts (primary + shared/group) that can hold FileNodes: any non-primary
// account that advertises the filenode capability or is non-personal, since
// Stalwart doesn't always advertise capabilities on shared accounts.
function filesCapableAccountIds(): string[] {
const primaryId = filesAccountId();
const accounts = jmapClient.currentSession?.accounts ?? {};
const rest = Object.entries(accounts)
.filter(([id, info]) =>
id !== primaryId &&
(info.accountCapabilities?.[CAPABILITIES.FILES] != null || !info.isPersonal))
.map(([id]) => id);
return [primaryId, ...rest];
}
// Fetch every FileNode the logged-in user can see across all accessible
// accounts. Nodes owned by another principal (shared with the user) are
// tagged `isShared: true` with the owning accountId/accountName, and their
// ids/parentIds are namespaced "accountId:nodeId". Mirrors the webmail's
// listAllFileNodesAcrossAccounts.
export async function getAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
const primaryId = filesAccountId();
const accounts = jmapClient.currentSession?.accounts ?? {};
const all: FileNode[] = [];
for (const accountId of filesCapableAccountIds()) {
const isPrimary = accountId === primaryId;
try {
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
fileUsing(),
);
const result = res.methodResponses[0];
if (!result || result[0] === 'error') continue;
for (const node of (result[1].list ?? []) as FileNode[]) {
all.push({
...node,
name: decodeFileNodeName(node.name),
id: isPrimary ? node.id : `${accountId}:${node.id}`,
parentId: node.parentId == null
? null
: (isPrimary ? node.parentId : `${accountId}:${node.parentId}`),
accountId,
accountName: accounts[accountId]?.name || accountId,
isShared: !isPrimary,
});
}
} catch {
// A single unreachable shared account shouldn't hide the user's own
// files; skip it and keep aggregating.
}
}
return all;
}
export async function createFolder(
name: string,
parentId: string | null,
): Promise<FileNode> {
return createFileNode({ name, parentId, type: 'd', blobId: null, size: 0 });
const accountId = filesAccountId();
const props: Record<string, unknown> = { name };
if (parentId !== null) props.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/set', { accountId, create: { 'new-dir': props } }, '0']],
fileUsing(),
);
const result = res.methodResponses[0][1];
const created = result.created?.['new-dir'];
if (!created) {
const err = result.notCreated?.['new-dir'];
throw new Error(err?.description || 'Create folder failed');
}
return { ...props, ...created } as FileNode;
}
export async function updateFileNode(
id: string,
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
USING,
fileUsing(),
);
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
}
export async function renameFileNode(id: string, newName: string): Promise<void> {
await updateFileNode(id, { name: newName });
}
// Re-parent a node. `null` moves it to the drive root (the property must be
// sent explicitly as null, omitting it would leave the node where it is).
export async function moveFileNode(id: string, parentId: string | null): Promise<void> {
await updateFileNode(id, { parentId });
}
export async function deleteFileNodes(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
await jmapClient.request(
[['FileNode/set', { accountId, destroy: ids }, '0']],
USING,
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
destroy: ids,
// The server removes descendant nodes of destroyed folders.
onDestroyRemoveChildren: true,
}, '0']],
fileUsing(),
);
const notDestroyed = res.methodResponses[0][1].notDestroyed as
| Record<string, { description?: string }>
| undefined;
const failedIds = Object.keys(notDestroyed ?? {});
if (failedIds.length > 0) {
throw new Error(
notDestroyed![failedIds[0]]?.description || `Failed to delete ${failedIds.length} item(s)`,
);
}
}
export function getFileNodeDownloadUrl(node: FileNode): string {
if (!node.blobId) throw new Error('Folders cannot be downloaded');
return getDownloadUrl(node.blobId, node.name, node.type);
return getDownloadUrl(node.blobId, node.name, node.type, node.accountId);
}
// Stalwart caps the stored MIME type; very long types fail the create.
function safeMimeType(type: string | undefined, fallback: string): string {
const t = type || fallback || 'application/octet-stream';
return t.length > 30 ? 'application/octet-stream' : t;
}
async function createFileNodeFromBlob(
name: string,
blobId: string,
type: string,
size: number | undefined,
parentId: string | null,
): Promise<FileNode> {
const accountId = filesAccountId();
const props: Record<string, unknown> = { name, type, blobId };
if (size != null) props.size = size;
if (parentId !== null) props.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/set', { accountId, create: { 'new-file': props } }, '0']],
fileUsing(),
);
const result = res.methodResponses[0];
if (!result || result[0] === 'error') {
throw new Error(result?.[1]?.description || 'FileNode/set create failed');
}
const created = result[1].created?.['new-file'];
if (!created) {
const err = result[1].notCreated?.['new-file'];
throw new Error(err?.description || 'Upload failed');
}
return { ...props, ...created } as FileNode;
}
export async function uploadFileNode(
@@ -90,13 +277,86 @@ export async function uploadFileNode(
name: string,
mimeType: string,
parentId: string | null,
options: UploadBlobOptions = {},
): Promise<FileNode> {
const blob = await uploadBlob(uri, mimeType);
return createFileNode({
const blob = await uploadBlob(uri, mimeType, options);
return createFileNodeFromBlob(
name,
blob.blobId,
safeMimeType(blob.type, mimeType),
blob.size,
parentId,
type: blob.type || mimeType || 'application/octet-stream',
blobId: blob.blobId,
size: blob.size,
});
);
}
// Copy a file by creating a new node that references the same blob — no
// bytes are re-uploaded (webmail `copyFileNode`). Folders have no blob and
// cannot be duplicated this way.
export async function copyFileNode(
node: FileNode,
parentId: string | null,
newName: string = node.name,
): Promise<FileNode> {
if (!node.blobId) throw new Error('Folders cannot be duplicated');
return createFileNodeFromBlob(
newName,
node.blobId,
safeMimeType(node.type, 'application/octet-stream'),
node.size,
parentId,
);
}
// ── Sharing (RFC 9670) ────────────────────────────────────
// Add, update, or remove a principal's rights on an owned FileNode (file or
// folder). Pass rights: null to revoke. Stalwart applies it via a
// `shareWith/{principalId}` patch on FileNode/set. Sharing a folder shares
// its whole subtree — which is why this app must store real parentId
// hierarchy rather than the legacy flat-name encoding.
export async function setFileNodeShare(
fileNodeId: string,
principalId: string,
rights: FileNodeRights | null,
): Promise<void> {
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } },
}, '0']],
fileUsing(),
);
const result = res.methodResponses[0][1];
if (result.notUpdated?.[fileNodeId]) {
throw new Error(result.notUpdated[fileNodeId].description || 'Failed to update file share');
}
if (!result.updated || !(fileNodeId in result.updated)) {
throw new Error('Server did not confirm the share update');
}
}
// List all principals visible to the user. Stalwart returns the full
// directory regardless of `filter`, so callers filter client-side.
export async function getPrincipals(): Promise<Principal[]> {
if (!supportsSharing()) return [];
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[
['Principal/query', { accountId }, '0'],
['Principal/get', {
accountId,
'#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' },
}, '1'],
],
[CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS],
);
const getResp = res.methodResponses.find((r) => r[0] === 'Principal/get');
return (getResp?.[1].list ?? []) as Principal[];
}
// The principal id that represents the logged-in user (excluded from the
// share picker — sharing with yourself is a no-op the server may reject).
export function ownPrincipalId(): string {
return filesAccountId();
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Serialises the FIRST request that touches a lazily-initialised server
* collection, per account. Port of the webmail's lib/jmap/first-touch-gate.ts.
*
* Stalwart creates an account's default calendar (and default address book) on
* the first access to an account that has none. On a single node that creation
* is guarded by the node's in-memory resource cache, but in clustered / multi-
* node deployments each node builds its own cache, so concurrent first-touch
* requests that land on different nodes each see "no calendars" and each create
* a default - leaving the user with two identical "Personal" calendars, one of
* which is flagged `isDefault` and can't be removed from the UI (#907).
*
* The app's login fans out `Calendar/get`, `CalendarEvent/query` (and
* `AddressBook/get`, `ContactCard/query`) within a few milliseconds of each
* other, which makes that race trivially winnable. This gate holds every later
* request on a (collection, account) pair until the first one has settled -
* success or failure - after which requests flow with no further coordination.
* Method calls outside the gated collections are never delayed.
*/
type MethodCall = [string, Record<string, unknown>, string];
export type GatedCollection = 'calendar' | 'contacts';
const COLLECTION_PREFIXES: ReadonlyArray<readonly [GatedCollection, string]> = [
['calendar', 'Calendar/'],
['calendar', 'CalendarEvent/'],
['calendar', 'CalendarEventNotification/'],
['contacts', 'AddressBook/'],
['contacts', 'ContactCard/'],
];
function collectionOf(method: string): GatedCollection | null {
for (const [collection, prefix] of COLLECTION_PREFIXES) {
if (method.startsWith(prefix)) return collection;
}
return null;
}
/**
* The `collection:accountId` keys a request touches. Calls without a string
* accountId are skipped - they'd be rejected by the server anyway.
*/
export function gateKeysFor(methodCalls: ReadonlyArray<MethodCall>): string[] {
const keys = new Set<string>();
for (const call of methodCalls) {
const collection = collectionOf(call[0]);
const accountId = call[1]?.accountId;
if (collection && typeof accountId === 'string' && accountId) {
keys.add(`${collection}:${accountId}`);
}
}
return Array.from(keys);
}
export class FirstTouchGate {
private settled = new Set<string>();
private inFlight = new Map<string, Promise<void>>();
/**
* Run `send` for `methodCalls`, delaying it while another request is the
* first to touch one of the collections it uses. Resolves/rejects with
* whatever `send` does.
*/
async run<T>(methodCalls: ReadonlyArray<MethodCall>, send: () => Promise<T>): Promise<T> {
const pending = gateKeysFor(methodCalls).filter((key) => !this.settled.has(key));
if (pending.length === 0) return send();
const waits = pending
.map((key) => this.inFlight.get(key))
.filter((p): p is Promise<void> => p !== undefined);
if (waits.length > 0) {
await Promise.all(waits);
// Those keys are settled now; anything still pending belongs to another
// collection/account this request happens to be first on.
return this.run(methodCalls, send);
}
// First touch for every pending key: run it and let the others queue.
const result = send();
const done = result.then(
() => undefined,
() => undefined,
);
for (const key of pending) this.inFlight.set(key, done);
try {
return await result;
} finally {
for (const key of pending) {
this.settled.add(key);
this.inFlight.delete(key);
}
}
}
/** Forget everything - e.g. after reconnecting as a different account. */
reset(): void {
this.settled.clear();
this.inFlight.clear();
}
}
+35 -18
View File
@@ -1,28 +1,43 @@
import { jmapClient } from './jmap-client';
import { assertSetResult, requireMethodResult } from './jmap-result';
import { CAPABILITIES } from './types';
import type { Identity } from './types';
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
export async function getIdentities(): Promise<Identity[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Identity/get', { accountId }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
return res.methodResponses[0][1].list;
const USING = [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION];
// A display name that carries an address or a line break produces a malformed
// `From:` on every message sent with the identity (#672); reduce it to the
// bare name before it reaches the server.
function sanitizeIdentityPatch(identity: Partial<Identity>): Partial<Identity> {
if (typeof identity.name !== 'string') return identity;
return { ...identity, name: sanitizeDisplayName(identity.name) };
}
export async function getIdentities(accountIdOverride?: string): Promise<Identity[]> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([['Identity/get', { accountId }, '0']], USING);
const body = requireMethodResult<{ list?: Identity[] }>(res, '0', 'Identity/get');
return body.list ?? [];
}
export async function createIdentity(
identity: Partial<Identity>,
): Promise<Identity> {
const accountId = jmapClient.accountId;
const cid = 'new-identity';
const res = await jmapClient.request(
[['Identity/set', {
accountId,
create: { 'new-identity': identity },
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
[['Identity/set', { accountId, create: { [cid]: sanitizeIdentityPatch(identity) } }, '0']],
USING,
);
return res.methodResponses[0][1].created['new-identity'];
const body = requireMethodResult(res, '0', 'Identity/set');
assertSetResult(body, [cid], 'identity');
const created = body.created?.[cid] as Partial<Identity> | undefined;
if (!created?.id) throw new Error('Identity/set returned no id');
// The create echo carries only server-set properties; re-read the full
// object so the caller gets name/email/signature back.
const all = await getIdentities();
return all.find((i) => i.id === created.id) ?? ({ ...identity, ...created } as Identity);
}
export async function updateIdentity(
@@ -30,16 +45,18 @@ export async function updateIdentity(
changes: Partial<Identity>,
): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['Identity/set', { accountId, update: { [id]: changes } }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
const res = await jmapClient.request(
[['Identity/set', { accountId, update: { [id]: sanitizeIdentityPatch(changes) } }, '0']],
USING,
);
assertSetResult(requireMethodResult(res, '0', 'Identity/set'), [id], 'identity');
}
export async function deleteIdentity(id: string): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
const res = await jmapClient.request(
[['Identity/set', { accountId, destroy: [id] }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
USING,
);
assertSetResult(requireMethodResult(res, '0', 'Identity/set'), [id], 'identity');
}
+831 -56
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
// Shared JMAP response helpers. Kept dependency-free (no client import) so
// every `src/api/*.ts` module - and their tests, which mock `./jmap-client`
// wholesale - can use them.
//
// Most RN api functions used to index `methodResponses[0][1]` blindly, so a
// method-level `['error', {...}]` reply produced a TypeError on reads and was
// silently treated as success on writes (the outbox then dropped the op).
// Route every response through these instead.
import type { JMAPResponseBody } from './types';
type MethodResponse = [string, Record<string, any>, string];
export class JMAPMethodError extends Error {
type: string;
constructor(type: string, description?: string) {
super(description || type || 'JMAP method error');
this.name = 'JMAPMethodError';
this.type = type;
}
}
/**
* Pull the response body for `callId` (or the first response when omitted)
* and throw a `JMAPMethodError` when the server answered with the error
* envelope. Also verifies the method name when `expectMethod` is given.
*/
export function requireMethodResult<T = Record<string, any>>(
res: JMAPResponseBody,
callId?: string,
expectMethod?: string,
): T {
const responses = (res?.methodResponses ?? []) as MethodResponse[];
const match = callId === undefined
? responses[0]
: responses.find((r) => r[2] === callId);
if (!match) {
throw new JMAPMethodError(
'missingResponse',
`Missing JMAP response${callId !== undefined ? ` for call ${callId}` : ''}`,
);
}
if (match[0] === 'error') {
const err = match[1] as { type?: string; description?: string };
throw new JMAPMethodError(err.type ?? 'serverFail', err.description);
}
if (expectMethod && match[0] !== expectMethod) {
throw new JMAPMethodError('unexpectedMethod', `Expected ${expectMethod}, got ${match[0]}`);
}
return match[1] as T;
}
/**
* Throw when a /set response reports the given ids (or any id when `ids` is
* omitted) under `notCreated` / `notUpdated` / `notDestroyed`.
*/
export function assertSetResult(
body: Record<string, any>,
ids?: string[],
what = 'object',
): void {
const buckets = ['notCreated', 'notUpdated', 'notDestroyed'] as const;
for (const bucket of buckets) {
const failures = body?.[bucket] as
| Record<string, { type?: string; description?: string; properties?: string[] }>
| undefined;
if (!failures) continue;
const entries = Object.entries(failures).filter(([id]) => !ids || ids.includes(id));
if (entries.length === 0) continue;
const [id, err] = entries[0];
const verb = bucket === 'notCreated' ? 'create' : bucket === 'notUpdated' ? 'update' : 'destroy';
const detail = [err?.type ?? 'unknown'];
if (err?.properties?.length) detail.push(`properties=[${err.properties.join(', ')}]`);
if (err?.description) detail.push(err.description);
const more = entries.length > 1 ? ` (+${entries.length - 1} more)` : '';
throw new JMAPMethodError(
err?.type ?? 'setError',
`Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`,
);
}
}
/** Split `items` into consecutive batches of at most `size` entries. */
export function batched<T>(items: T[], size: number): T[][] {
const step = Math.max(1, Math.floor(size));
const result: T[][] = [];
for (let i = 0; i < items.length; i += step) {
result.push(items.slice(i, i + step));
}
return result;
}
+75
View File
@@ -0,0 +1,75 @@
import { jmapClient } from './jmap-client';
// Port of the webmail's JMAPClient.discoverKeywords (#658): page through the
// account's messages newest-first (query + back-referenced Email/get of the
// keywords only) and count every keyword that is set. Capped like the
// offline sync so a noisy account can't be enumerated end to end.
export const DEFAULT_KEYWORD_SCAN_LIMIT = 5000;
export interface KeywordScanResult {
/** keyword → number of scanned messages carrying it. */
keywords: Record<string, number>;
scanned: number;
total: number;
/** False when the cap stopped the scan before the end of the list. */
complete: boolean;
}
export async function discoverKeywords(options?: {
limit?: number;
onProgress?: (scanned: number, total: number) => void;
signal?: { aborted: boolean };
accountId?: string;
}): Promise<KeywordScanResult> {
const accountId = options?.accountId ?? jmapClient.accountId;
const cap = Math.max(0, options?.limit ?? DEFAULT_KEYWORD_SCAN_LIMIT);
const pageSize = Math.max(1, Math.min(500, jmapClient.getMaxObjectsInGet()));
const keywords: Record<string, number> = {};
let scanned = 0;
let total = 0;
let complete = false;
while (scanned < cap) {
if (options?.signal?.aborted) break;
const limit = Math.min(pageSize, cap - scanned);
const res = await jmapClient.request([
['Email/query', {
accountId,
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
position: scanned,
calculateTotal: scanned === 0,
}, '0'],
['Email/get', {
accountId,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: ['keywords'],
}, '1'],
]);
const query = res.methodResponses?.[0]?.[1] ?? {};
const got = res.methodResponses?.[1]?.[1] ?? {};
const ids: string[] = (query.ids as string[] | undefined) ?? [];
if (scanned === 0) total = (query.total as number | undefined) ?? 0;
for (const email of (got.list as Array<{ keywords?: Record<string, boolean> }> | undefined) ?? []) {
for (const [keyword, isSet] of Object.entries(email.keywords ?? {})) {
if (isSet) keywords[keyword] = (keywords[keyword] ?? 0) + 1;
}
}
// Page by what the query returned, not by what the get did: a message
// destroyed between the two lands in `notFound` and would otherwise
// shift every later page by one.
scanned += ids.length;
options?.onProgress?.(scanned, Math.max(total, scanned));
// A short page is the end of the list, however much `total` claimed.
if (ids.length === 0 || ids.length < limit) {
complete = true;
break;
}
}
return { keywords, scanned, total: Math.max(total, scanned), complete };
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Naming one member of an object in a JMAP PatchObject. Port of the webmail's
* lib/jmap/patch-pointer.ts.
*
* A PatchObject key is a JSON Pointer (RFC 8620 section 5.3), so a slash in the
* member's own name starts a new path segment unless it is escaped. That bites
* keywords: a nested tag is stored as `$label:work/clients`, and the unescaped
* pointer `keywords/$label:work/clients` asks for the `clients` member of the
* `$label:work` keyword - not a boolean, so the patch is rejected or ignored
* and the tag never lands.
*/
/** One JSON Pointer reference token: `~` -> `~0`, `/` -> `~1` (RFC 6901). */
export function pointerToken(value: string): string {
return value.replace(/~/g, '~0').replace(/\//g, '~1');
}
/**
* The member name a reference token stands for: the inverse of
* `pointerToken`. `~1` is read before `~0`, per RFC 6901 - the other order
* would turn the escaped `~1` of `~01` back into a slash.
*/
export function pointerTokenValue(token: string): string {
return token.replace(/~1/g, '/').replace(/~0/g, '~');
}
/** The PatchObject key that sets or clears the keyword `keyword`. */
export function keywordPointer(keyword: string): string {
return `keywords/${pointerToken(keyword)}`;
}
/** The PatchObject key that adds or removes membership of `mailboxId`. */
export function mailboxPointer(mailboxId: string): string {
return `mailboxIds/${pointerToken(mailboxId)}`;
}
+374
View File
@@ -0,0 +1,374 @@
// Live updates from the JMAP EventSource (SSE), with the lifecycle the plain
// `react-native-sse` EventSource lacks:
//
// - reconnect with back-off after a network error (the library only re-polls
// when the XHR reaches DONE with a status; a dropped socket - iOS
// suspension, Wi-Fi → cellular - fires `error` with status 0 and then
// nothing ever happens again);
// - a fresh Authorization header on every (re)connect, and an immediate
// reconnect when the OAuth token rotates (the library reuses the headers it
// was constructed with, so after a refresh every retry sent the stale
// bearer: 401 → error → retry every 5 s forever);
// - a 90 s ping watchdog and a periodic recycle so the accumulated
// `responseText` cannot grow without bound;
// - fallback to polling when the stream is refused (proxy without SSE, TLS
// client certificate that XHR cannot present, repeated failures).
import { jmapClient, AuthenticationError } from './jmap-client';
import { CAPABILITIES } from './types';
import type { StateChange } from './types';
import { getClientCertAlias } from '../lib/client-cert';
export type StateChangeHandler = (change: StateChange) => void;
// Server pings every 30 s; consider the stream dead after three missed ones.
const PING_SECONDS = 30;
const PING_TIMEOUT_MS = 90_000;
// Recycle a healthy stream periodically to bound the XHR buffer.
const RECYCLE_MS = 30 * 60_000;
const RECONNECT_DELAYS_MS = [2_000, 5_000, 10_000, 20_000, 40_000, 60_000];
// After this many consecutive failed connects the stream is given up on and
// polling takes over for the rest of the session.
const MAX_CONSECUTIVE_FAILURES = 6;
export const POLL_INTERVAL_MS = 5_000;
export const SLOW_POLL_INTERVAL_MS = 20_000;
interface SseEvent {
type?: string;
data?: string;
message?: string;
xhrStatus?: number;
}
interface EventSourceLike {
addEventListener: (type: string, handler: (event: SseEvent) => void) => void;
removeEventListener: (type: string, handler: (event: SseEvent) => void) => void;
close: () => void;
}
type EventSourceCtor = new (
url: string,
options: { headers: Record<string, string>; pollingInterval?: number; timeoutBeforeConnection?: number },
) => EventSourceLike;
function eventSourceImpl(): EventSourceCtor {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('react-native-sse').default as EventSourceCtor;
}
export interface LiveUpdatesOptions {
onStateChange: StateChangeHandler;
onError?: (error: Error) => void;
/** Called when the stream is abandoned for polling. */
onFallback?: (reason: string) => void;
/** Returns false while the app is backgrounded; polling pauses. */
isActive?: () => boolean;
}
export interface LiveUpdatesHandle {
/** Tear everything down (stream, timers, polling). */
close: () => void;
/** Drop the current stream and connect again (foreground resume). */
reconnect: () => void;
/** 'sse' | 'polling' | 'closed' */
readonly mode: 'sse' | 'polling' | 'closed';
}
function buildEventSourceUrl(template: string): string {
return template
.replace('{types}', '*')
.replace('{closeafter}', 'no')
.replace('{ping}', String(PING_SECONDS));
}
/**
* Polling fallback: `Mailbox/get` + `Email/get` with empty id lists return
* the current state tokens; a change in either is reported as a StateChange
* for the primary account. One request in flight at a time, paused while
* backgrounded.
*/
export function startPolling(
onStateChange: StateChangeHandler,
interval = POLL_INTERVAL_MS,
isActive: () => boolean = () => true,
): () => void {
let accountId: string;
try {
accountId = jmapClient.accountId;
} catch {
return () => undefined;
}
const stateCache: Record<string, string> = {};
let inFlight = false;
let stopped = false;
const tick = async () => {
if (stopped || inFlight || !isActive()) return;
if ('isConnected' in jmapClient && !jmapClient.isConnected) return;
inFlight = true;
try {
const res = await jmapClient.request(
[
['Mailbox/get', { accountId, ids: [] }, 'm'],
['Email/get', { accountId, ids: [] }, 'e'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const changed: Record<string, string> = {};
for (const [method, result] of res.methodResponses) {
if (method === 'error') continue;
const type = method.replace('/get', '');
const newState = (result as { state?: string }).state;
if (newState && stateCache[type] && stateCache[type] !== newState) {
changed[type] = newState;
}
if (newState) stateCache[type] = newState;
}
if (Object.keys(changed).length > 0) {
onStateChange({ '@type': 'StateChange', changed: { [accountId]: changed } });
}
} catch {
// Silently retry on next interval
} finally {
inFlight = false;
}
};
const timer = setInterval(() => { void tick(); }, interval);
return () => {
stopped = true;
clearInterval(timer);
};
}
/**
* Start live updates: SSE when the session advertises an eventSourceUrl and
* no client certificate is in play, polling otherwise. Resolves to a handle
* that can be closed or told to reconnect.
*/
export async function startLiveUpdates(opts: LiveUpdatesOptions): Promise<LiveUpdatesHandle> {
const isActive = opts.isActive ?? (() => true);
const safeOnStateChange: StateChangeHandler = (change) => {
try {
opts.onStateChange(change);
} catch (err) {
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
}
};
let mode: 'sse' | 'polling' | 'closed' = 'closed';
let stopPolling: (() => void) | null = null;
let es: EventSourceLike | null = null;
let esHandlers: Array<[string, (event: SseEvent) => void]> = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let watchdog: ReturnType<typeof setTimeout> | null = null;
let recycleTimer: ReturnType<typeof setTimeout> | null = null;
let consecutiveFailures = 0;
let closed = false;
let unsubscribeTokenRefresh: (() => void) | null = null;
const session = jmapClient.currentSession;
const template = session?.eventSourceUrl;
const certAlias = await getClientCertAlias().catch(() => null);
const clearTimers = () => {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (watchdog) { clearTimeout(watchdog); watchdog = null; }
if (recycleTimer) { clearTimeout(recycleTimer); recycleTimer = null; }
};
const dropStream = () => {
if (es) {
for (const [type, handler] of esHandlers) {
try { es.removeEventListener(type, handler); } catch { /* ignore */ }
}
esHandlers = [];
try { es.close(); } catch { /* ignore */ }
es = null;
}
if (watchdog) { clearTimeout(watchdog); watchdog = null; }
if (recycleTimer) { clearTimeout(recycleTimer); recycleTimer = null; }
};
const fallbackToPolling = (reason: string) => {
if (closed) return;
dropStream();
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (mode !== 'polling') {
mode = 'polling';
opts.onFallback?.(reason);
stopPolling = startPolling(safeOnStateChange, SLOW_POLL_INTERVAL_MS, isActive);
}
};
const scheduleReconnect = () => {
if (closed || reconnectTimer) return;
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
fallbackToPolling('stream failed repeatedly');
return;
}
const delay = RECONNECT_DELAYS_MS[Math.min(consecutiveFailures, RECONNECT_DELAYS_MS.length - 1)];
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void connect();
}, delay);
};
const armWatchdog = () => {
if (watchdog) clearTimeout(watchdog);
watchdog = setTimeout(() => {
// No ping for 90 s: the socket is dead even though the XHR never said so.
dropStream();
consecutiveFailures += 1;
scheduleReconnect();
}, PING_TIMEOUT_MS);
};
const connect = async () => {
if (closed || !template) return;
if (!isActive()) return; // resumed later by reconnect()
dropStream();
try {
await jmapClient.ensureFreshToken();
} catch {
// the 401 path below handles a stale token
}
let authHeader: string;
try {
authHeader = jmapClient.authHeader;
} catch {
return;
}
const url = buildEventSourceUrl(template);
let source: EventSourceLike;
try {
const Impl = eventSourceImpl();
// pollingInterval 0 disables the library's own re-poll on DONE; every
// reconnect goes through scheduleReconnect so headers stay fresh.
source = new Impl(url, {
headers: { Authorization: authHeader },
pollingInterval: 0,
timeoutBeforeConnection: 0,
});
} catch (err) {
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
consecutiveFailures += 1;
scheduleReconnect();
return;
}
es = source;
mode = 'sse';
const on = (type: string, handler: (event: SseEvent) => void) => {
source.addEventListener(type, handler);
esHandlers.push([type, handler]);
};
on('open', () => {
consecutiveFailures = 0;
armWatchdog();
if (recycleTimer) clearTimeout(recycleTimer);
recycleTimer = setTimeout(() => {
if (closed || es !== source) return;
dropStream();
void connect();
}, RECYCLE_MS);
});
on('ping', () => armWatchdog());
on('state', (event) => {
armWatchdog();
try {
const data: StateChange = JSON.parse(typeof event === 'string' ? event : event.data ?? '');
safeOnStateChange(data);
} catch {
// Ignore malformed events
}
});
on('close', () => {
if (closed || es !== source) return;
dropStream();
scheduleReconnect();
});
on('error', (event) => {
if (closed || es !== source) return;
dropStream();
const status = event?.xhrStatus ?? 0;
if (status === 401) {
// Stale bearer: refresh and come straight back with the new header.
void jmapClient.forceRefreshToken().then(
(refreshed) => {
if (closed) return;
if (refreshed) {
consecutiveFailures = 0;
void connect();
} else {
consecutiveFailures += 1;
opts.onError?.(new AuthenticationError('Live updates rejected: session expired'));
scheduleReconnect();
}
},
() => {
consecutiveFailures += 1;
scheduleReconnect();
},
);
return;
}
if (status >= 400) {
// The endpoint answered but refused the stream (proxy without SSE,
// rate limit, server error): don't hammer it.
consecutiveFailures += 2;
opts.onError?.(new Error(`Live updates stream refused: ${status}`));
scheduleReconnect();
return;
}
// status 0: socket dropped / no network. Back off and retry.
consecutiveFailures += 1;
scheduleReconnect();
});
};
if (!template || certAlias) {
mode = 'polling';
opts.onFallback?.(certAlias ? 'client certificate in use' : 'server has no eventSourceUrl');
stopPolling = startPolling(safeOnStateChange, POLL_INTERVAL_MS, isActive);
} else {
unsubscribeTokenRefresh = jmapClient.onTokenRefresh(() => {
if (closed || mode !== 'sse') return;
// New bearer: the open stream still carries the old one for its
// lifetime, which is fine, but every reconnect from now on must use
// the new header - recycle now so a pending stale retry can't loop.
dropStream();
consecutiveFailures = 0;
void connect();
});
await connect();
}
return {
get mode() {
return mode;
},
close: () => {
closed = true;
mode = 'closed';
clearTimers();
dropStream();
unsubscribeTokenRefresh?.();
unsubscribeTokenRefresh = null;
stopPolling?.();
stopPolling = null;
},
reconnect: () => {
if (closed) return;
if (mode === 'polling') return; // polling keeps running on its own
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
consecutiveFailures = 0;
dropStream();
void connect();
},
};
}
+27 -106
View File
@@ -1,6 +1,6 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { PushSubscription, StateChange } from './types';
import type { EmailPushConfig, PushSubscription, StateChange } from './types';
export type StateChangeHandler = (change: StateChange) => void;
@@ -27,6 +27,9 @@ export async function createPushSubscription(params: {
// ISO date. Servers may clamp to their own ceiling - we send the maximum we
// want and accept whatever Stalwart returns.
expires?: string;
// draft-ietf-jmap-emailpush delivery filter, only when the server advertises
// urn:ietf:params:jmap:emailpush (see serverSupportsEmailPush).
emailPush?: Record<string, EmailPushConfig>;
}): Promise<string> {
const created: Record<string, unknown> = {
deviceClientId: params.deviceClientId,
@@ -34,6 +37,7 @@ export async function createPushSubscription(params: {
types: params.types,
};
if (params.expires) created.expires = params.expires;
if (params.emailPush) created.emailPush = params.emailPush;
const res = await jmapClient.request(
[
@@ -63,7 +67,7 @@ export async function createPushSubscription(params: {
*/
export async function updatePushSubscription(
id: string,
patch: { expires?: string; types?: string[] },
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> },
): Promise<boolean> {
const res = await jmapClient.request(
[
@@ -113,123 +117,40 @@ export async function destroyPushSubscription(id: string): Promise<void> {
);
}
/**
* Connect to the JMAP EventSource (SSE) endpoint for real-time push updates.
* Returns a cleanup function to close the connection.
*/
export function connectEventSource(
onStateChange: StateChangeHandler,
): (() => void) | null {
const session = jmapClient.currentSession;
if (!session?.eventSourceUrl) return null;
// ─── Live updates ────────────────────────────────────────
// The EventSource lifecycle (reconnect with back-off, fresh bearer on every
// connect, ping watchdog, polling fallback) lives in ./push-stream. These
// re-exports keep the historical entry points.
const url = session.eventSourceUrl
.replace('{types}', '*')
.replace('{closeafter}', 'no')
.replace('{ping}', '30');
// Use native EventSource or react-native-sse
const EventSourceImpl = typeof EventSource !== 'undefined'
? EventSource
: require('react-native-sse').default;
const es = new EventSourceImpl(url, {
headers: { Authorization: jmapClient.authHeader },
});
const handler = (event: any) => {
try {
const data: StateChange = JSON.parse(
typeof event === 'string' ? event : event.data,
);
onStateChange(data);
} catch {
// Ignore malformed events
}
};
es.addEventListener('state', handler);
return () => {
es.removeEventListener('state', handler);
es.close();
};
}
/**
* Polling fallback when SSE is not available.
* Returns a cleanup function to stop polling.
*/
export function startPolling(
onStateChange: StateChangeHandler,
interval = 5000,
): () => void {
const accountId = jmapClient.accountId;
const stateCache: Record<string, string> = {};
const timer = setInterval(async () => {
try {
const res = await jmapClient.request(
[
['Mailbox/get', { accountId, ids: [] }, 'm'],
['Email/get', { accountId, ids: [] }, 'e'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const changed: Record<string, string> = {};
for (const [method, result] of res.methodResponses) {
const type = method.replace('/get', '');
const newState = result.state;
if (newState && stateCache[type] && stateCache[type] !== newState) {
changed[type] = newState;
}
if (newState) stateCache[type] = newState;
}
if (Object.keys(changed).length > 0) {
onStateChange({
'@type': 'StateChange',
changed: { [accountId]: changed },
});
}
} catch {
// Silently retry on next interval
}
}, interval);
return () => clearInterval(timer);
}
export {
startLiveUpdates,
startPolling,
type LiveUpdatesHandle,
type LiveUpdatesOptions,
} from './push-stream';
export interface StartPushOptions {
onStateChange: StateChangeHandler;
onError?: (error: Error) => void;
onFallback?: (reason: string) => void;
isActive?: () => boolean;
}
/**
* Start real-time updates, preferring SSE with polling fallback. The `_client`
* argument is unused today (the underlying event source uses the singleton
* `jmapClient`) but is part of the call signature so the host App can pass
* the active client reference and trigger React re-runs on account switch.
* Start real-time updates, preferring SSE with polling fallback. Resolves to
* a cleanup function; prefer `startLiveUpdates` when the handle's
* `reconnect()` is needed (foreground resume).
*/
export function startPushUpdates(
export async function startPushUpdates(
_client: unknown,
opts: StartPushOptions,
): () => void {
const onError = opts.onError;
const safeOnStateChange: StateChangeHandler = (change) => {
try {
opts.onStateChange(change);
} catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
}
};
): Promise<() => void> {
const { startLiveUpdates: start } = await import('./push-stream');
try {
const cleanup = connectEventSource(safeOnStateChange);
if (cleanup) return cleanup;
return startPolling(safeOnStateChange);
const handle = await start(opts);
return () => handle.close();
} catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
return () => undefined;
}
}
+55
View File
@@ -0,0 +1,55 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
// RFC 9425 Quota object (Stalwart reports storage as resourceType "octets"
// with scope "account"); older servers used a pre-RFC "mail" shape.
interface JMAPQuota {
id: string;
resourceType?: string;
used?: number;
hardLimit?: number;
warnLimit?: number;
softLimit?: number;
// pre-RFC shape
limit?: number;
scope?: string;
types?: string[];
name?: string;
}
export interface MailQuota {
used: number;
total: number;
}
export function serverSupportsQuota(): boolean {
return Boolean(jmapClient.currentSession?.capabilities?.[CAPABILITIES.QUOTA]);
}
/**
* Storage usage for the primary account, or null when the server does not
* advertise urn:ietf:params:jmap:quota or reports no mail storage quota.
* Mirrors the webmail's client.getQuota().
*/
export async function fetchMailQuota(): Promise<MailQuota | null> {
if (!serverSupportsQuota()) return null;
try {
const res = await jmapClient.request(
[['Quota/get', { accountId: jmapClient.accountId }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.QUOTA],
);
const [name, body] = res.methodResponses[0] ?? [];
if (name !== 'Quota/get') return null;
const quotas = (body?.list ?? []) as JMAPQuota[];
const coversMail = (q: JMAPQuota) =>
!q.types?.length || q.types.some((t) => t === 'Email' || t === 'Mail');
const mailQuota =
quotas.find((q) => q.resourceType === 'octets' && coversMail(q))
|| quotas.find((q) => q.resourceType === 'mail' || q.scope === 'mail');
if (!mailQuota) return null;
const total = mailQuota.hardLimit ?? mailQuota.limit ?? 0;
return { used: mailQuota.used ?? 0, total };
} catch {
return null;
}
}
+105
View File
@@ -0,0 +1,105 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { EmailAddress } from './types';
export interface RecentRecipient {
name: string;
email: string;
}
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
function collect(
emails: Array<{ to?: EmailAddress[] | null; cc?: EmailAddress[] | null }>,
keep: (key: string, name: string) => boolean = () => true,
): RecentRecipient[] {
// Messages arrive newest-first, so the first occurrence per address is the
// most recent one and carries the freshest display name.
const byEmail = new Map<string, RecentRecipient>();
for (const email of emails) {
for (const r of [...(email.to || []), ...(email.cc || [])]) {
if (!r?.email) continue;
const key = r.email.toLowerCase().trim();
if (!key || byEmail.has(key)) continue;
const name = (r.name || '').trim();
if (!keep(key, name)) continue;
byEmail.set(key, { name, email: r.email });
}
}
return Array.from(byEmail.values());
}
/**
* The people the user has written to: scans the newest `limit` messages of the
* Sent mailbox and returns each To/Cc address once (OWA-style autocomplete
* cache). Only the recipient headers are fetched - no subject, preview or body.
*/
export async function queryRecentRecipients(
sentMailboxId: string,
limit = 300,
accountId?: string,
): Promise<RecentRecipient[]> {
if (!sentMailboxId) return [];
const account = accountId || jmapClient.accountId;
const res = await jmapClient.request([
['Email/query', {
accountId: account,
filter: { inMailbox: sentMailboxId },
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0'],
['Email/get', {
accountId: account,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: ['to', 'cc'],
}, '1'],
], USING);
const getResponse = res.methodResponses?.find((r: unknown[]) => r[0] === 'Email/get');
const list = ((getResponse?.[1] as { list?: unknown[] } | undefined)?.list ?? []) as Array<{
to?: EmailAddress[] | null;
cc?: EmailAddress[] | null;
}>;
return collect(list);
}
/**
* On-demand server search of the Sent mailbox for recipients matching `query`
* (used when the local caches have no hit). Keeps only the addresses that
* actually match, not every co-recipient of a matching message.
*/
export async function searchSentRecipients(
query: string,
sentMailboxId: string,
limit = 60,
accountId?: string,
): Promise<RecentRecipient[]> {
const q = query.trim();
if (!q || !sentMailboxId) return [];
const account = accountId || jmapClient.accountId;
const res = await jmapClient.request([
['Email/query', {
accountId: account,
filter: {
operator: 'AND',
conditions: [
{ inMailbox: sentMailboxId },
{ operator: 'OR', conditions: [{ to: q }, { cc: q }] },
],
},
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0'],
['Email/get', {
accountId: account,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: ['to', 'cc'],
}, '1'],
], USING);
const getResponse = res.methodResponses?.find((r: unknown[]) => r[0] === 'Email/get');
const list = ((getResponse?.[1] as { list?: unknown[] } | undefined)?.list ?? []) as Array<{
to?: EmailAddress[] | null;
cc?: EmailAddress[] | null;
}>;
const lower = q.toLowerCase();
return collect(list, (key, name) => key.includes(lower) || name.toLowerCase().includes(lower));
}
+194
View File
@@ -0,0 +1,194 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import { secureFetch } from '../lib/client-cert';
import type { SieveScript, SieveCapabilities } from '../lib/sieve/types';
// JMAP Sieve (RFC 9661) bindings. Mirrors the webmail JMAPClient Sieve methods
// but follows the mobile convention of a functional api/* module driving the
// shared `jmapClient` singleton (see api/vacation.ts, api/blob.ts).
const SIEVE_USING = [CAPABILITIES.CORE, CAPABILITIES.SIEVE];
function requireSession() {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
return session;
}
// Sieve lives on its own JMAP account (RFC 9661). Fall back to the mail account
// when the server does not advertise a dedicated one (Stalwart uses the same id).
export function getSieveAccountId(): string {
const session = jmapClient.currentSession;
return session?.primaryAccounts?.[CAPABILITIES.SIEVE] ?? jmapClient.accountId;
}
export function isSieveSupported(): boolean {
const session = jmapClient.currentSession;
if (!session) return false;
return CAPABILITIES.SIEVE in (session.capabilities ?? {});
}
export function getSieveCapabilities(): SieveCapabilities | null {
const session = jmapClient.currentSession;
if (!session) return null;
const info = session.accounts?.[getSieveAccountId()];
const caps = info?.accountCapabilities?.[CAPABILITIES.SIEVE];
return (caps as SieveCapabilities) ?? null;
}
export async function getSieveScripts(): Promise<SieveScript[]> {
const res = await jmapClient.request(
[['SieveScript/get', { accountId: getSieveAccountId() }, '0']],
SIEVE_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/get') {
return ((resp[1] as { list?: SieveScript[] }).list ?? []) as SieveScript[];
}
throw new Error('Failed to fetch Sieve scripts');
}
export async function getSieveScriptContent(blobId: string): Promise<string> {
const session = requireSession();
const url = session.downloadUrl
.replace('{accountId}', encodeURIComponent(getSieveAccountId()))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent('script.sieve'))
.replace('{type}', encodeURIComponent('application/sieve'));
const response = await secureFetch(url, {
headers: { Authorization: jmapClient.authHeader },
});
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
return response.text();
}
async function uploadSieveBlob(content: string): Promise<string> {
const session = requireSession();
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(getSieveAccountId()),
);
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/sieve',
Authorization: jmapClient.authHeader,
},
// A plain string body is encoded as UTF-8 by RN's fetch. (api/blob.ts uses
// an ArrayBuffer only because typed-array bodies get stringified there.)
body: content,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Failed to upload Sieve script: ${response.status}${detail ? ` ${detail.substring(0, 200)}` : ''}`);
}
const raw = (await response.json()) as Record<string, unknown>;
const direct = raw as { blobId?: string };
if (typeof direct.blobId === 'string') return direct.blobId;
const nested = raw[getSieveAccountId()] as { blobId?: string } | undefined;
if (nested?.blobId) return nested.blobId;
throw new Error('Upload succeeded but response did not include a blobId');
}
export async function createSieveScript(
name: string,
content: string,
activate = true,
): Promise<SieveScript> {
const blobId = await uploadSieveBlob(content);
const accountId = getSieveAccountId();
const setArgs: Record<string, unknown> = {
accountId,
create: { 'new-script': { name, blobId } },
};
if (activate) setArgs.onSuccessActivateScript = '#new-script';
const res = await jmapClient.request([['SieveScript/set', setArgs, '0']], SIEVE_USING);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/set') {
const result = resp[1] as {
notCreated?: Record<string, { description?: string }>;
created?: Record<string, { id?: string }>;
};
if (result.notCreated?.['new-script']) {
throw new Error(result.notCreated['new-script'].description ?? 'Failed to create Sieve script');
}
const createdId = result.created?.['new-script']?.id;
if (createdId) {
const scripts = await getSieveScripts();
const script = scripts.find((s) => s.id === createdId);
if (script) return script;
}
}
throw new Error('Failed to create Sieve script');
}
export async function updateSieveScript(
scriptId: string,
content: string,
activate = true,
): Promise<void> {
const blobId = await uploadSieveBlob(content);
const accountId = getSieveAccountId();
const setArgs: Record<string, unknown> = {
accountId,
update: { [scriptId]: { blobId } },
};
if (activate) setArgs.onSuccessActivateScript = scriptId;
const res = await jmapClient.request([['SieveScript/set', setArgs, '0']], SIEVE_USING);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/set') {
const result = resp[1] as { notUpdated?: Record<string, { description?: string }> };
if (result.notUpdated?.[scriptId]) {
throw new Error(result.notUpdated[scriptId].description ?? 'Failed to update Sieve script');
}
return;
}
throw new Error('Failed to update Sieve script');
}
export async function deleteSieveScript(scriptId: string): Promise<void> {
const res = await jmapClient.request(
[['SieveScript/set', { accountId: getSieveAccountId(), destroy: [scriptId] }, '0']],
SIEVE_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/set') {
const result = resp[1] as { notDestroyed?: Record<string, { description?: string }> };
if (result.notDestroyed?.[scriptId]) {
throw new Error(result.notDestroyed[scriptId].description ?? 'Failed to delete Sieve script');
}
return;
}
throw new Error('Failed to delete Sieve script');
}
export async function validateSieveScript(
content: string,
): Promise<{ isValid: boolean; errors?: string[] }> {
const blobId = await uploadSieveBlob(content);
const res = await jmapClient.request(
[['SieveScript/validate', { accountId: getSieveAccountId(), blobId }, '0']],
SIEVE_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/validate') {
const result = resp[1] as { error?: { description?: string } };
if (result.error) {
return { isValid: false, errors: [result.error.description ?? 'Validation failed'] };
}
return { isValid: true };
}
if (resp && typeof resp[0] === 'string' && resp[0].endsWith('error')) {
const error = resp[1] as { description?: string };
return { isValid: false, errors: [error.description ?? 'Validation failed'] };
}
return { isValid: false, errors: ['Unexpected validation response'] };
}
-64
View File
@@ -1,64 +0,0 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { EmailAddress } from './types';
export async function sendEmail(
email: {
from: EmailAddress[];
to: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
subject: string;
htmlBody?: string;
textBody?: string;
attachments?: Array<{ blobId: string; type: string; name: string }>;
inReplyTo?: string;
references?: string;
},
identityId: string,
sentMailboxId: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const emailCreate: Record<string, unknown> = {
from: email.from,
to: email.to,
cc: email.cc,
bcc: email.bcc,
subject: email.subject,
mailboxIds: { [sentMailboxId]: true },
keywords: { $seen: true },
};
if (email.htmlBody) {
emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }];
emailCreate.bodyValues = { html: { value: email.htmlBody } };
} else {
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
emailCreate.bodyValues = { text: { value: email.textBody ?? '' } };
}
if (email.attachments?.length) {
emailCreate.attachments = email.attachments.map((a) => ({
blobId: a.blobId,
type: a.type,
name: a.name,
disposition: 'attachment',
}));
}
if (email.inReplyTo) {
emailCreate['header:In-Reply-To:asText'] = email.inReplyTo;
emailCreate['header:References:asText'] = email.references ?? email.inReplyTo;
}
await jmapClient.request(
[
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
['EmailSubmission/set', {
accountId,
create: { 'sub-1': { emailId: '#draft', identityId } },
}, '1'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
);
}
+59
View File
@@ -0,0 +1,59 @@
import { jmapClient } from './jmap-client';
import type { JMAPMethodCall } from './types';
export interface TagCount {
/** Tag id (the part after `$label:`). */
id: string;
total: number;
unread: number;
}
/**
* Unread/total counts per tag across every folder of one account — two
* `Email/query` calls (`calculateTotal`, `limit: 0`) per tag, batched to the
* server's maxCallsInRequest. Port of the webmail's `fetchTagCounts`
* (stores/email-store.ts). A tag whose queries fail is reported as 0/0
* rather than sinking the whole batch.
*/
export async function fetchTagCounts(tagIds: string[], accountIdOverride?: string): Promise<TagCount[]> {
if (tagIds.length === 0) return [];
const accountId = accountIdOverride ?? jmapClient.accountId;
const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest());
const counts = new Map<string, TagCount>(tagIds.map((id) => [id, { id, total: 0, unread: 0 }]));
const calls: Array<{ id: string; kind: 'total' | 'unread'; call: JMAPMethodCall }> = [];
for (const id of tagIds) {
const keyword = `$label:${id}`;
calls.push({
id,
kind: 'total',
call: ['Email/query', { accountId, filter: { hasKeyword: keyword }, limit: 0, calculateTotal: true }, `t:${id}`],
});
calls.push({
id,
kind: 'unread',
call: ['Email/query', {
accountId,
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }] },
limit: 0,
calculateTotal: true,
}, `u:${id}`],
});
}
for (let i = 0; i < calls.length; i += perRequest) {
const chunk = calls.slice(i, i + perRequest);
const res = await jmapClient.request(chunk.map((c) => c.call));
for (const [name, body, callId] of res.methodResponses) {
if (name !== 'Email/query' || typeof callId !== 'string') continue;
const [kind, ...rest] = callId.split(':');
const id = rest.join(':');
const entry = counts.get(id);
if (!entry) continue;
const total = typeof body.total === 'number' ? body.total : 0;
if (kind === 't') entry.total = total;
else if (kind === 'u') entry.unread = total;
}
}
return tagIds.map((id) => counts.get(id)!);
}
+210 -12
View File
@@ -9,12 +9,19 @@ export interface JMAPSession {
accounts: Record<string, JMAPAccountInfo>;
capabilities: Record<string, unknown>;
state: string;
// RFC 8620 §2 — authenticated user identifier. Optional because not all
// servers populate it; OAuth login uses it as the account label when
// there's no password username to fall back on.
username?: string;
}
export interface JMAPAccountInfo {
name: string;
isPersonal: boolean;
isReadOnly: boolean;
// Per-account capability objects (RFC 8620 §2). Optional because not every
// server populates it; the Sieve panel reads its limits/extensions from here.
accountCapabilities?: Record<string, unknown>;
}
export type JMAPMethodCall = [string, Record<string, unknown>, string];
@@ -71,6 +78,14 @@ export interface Email {
subject?: string;
preview?: string;
hasAttachment: boolean;
// RFC 8621 §4.1.2.3 - bare msg-ids without angle brackets. Needed for
// reply threading (In-Reply-To / References) and read-receipt dedupe.
messageId?: string[] | null;
inReplyTo?: string[] | null;
references?: string[] | null;
// Raw headers (RFC 8621 §4.1.3) when fetched with `headers`; the viewer
// derives SPF/DKIM/DMARC chips, List-Unsubscribe, MDN requests from them.
headers?: Array<{ name: string; value: string }>;
textBody?: BodyPart[];
htmlBody?: BodyPart[];
bodyValues?: Record<string, { value: string; isEncodingProblem?: boolean }>;
@@ -92,7 +107,15 @@ export interface MailboxRights {
}
export interface Mailbox {
/**
* Unique within the app. For the user's own folders this is the raw JMAP id;
* for a shared (group) account's folders it's `<accountId>:<rawId>` so ids
* from different accounts can't collide. Use `originalId` when talking to
* the server. Mirrors the webmail's scheme in [lib/jmap/client.ts].
*/
id: string;
/** Raw JMAP id — set only on shared folders, where `id` carries a prefix. */
originalId?: string;
name: string;
parentId?: string | null;
role?: string | null;
@@ -103,6 +126,12 @@ export interface Mailbox {
unreadThreads: number;
myRights: MailboxRights;
isSubscribed?: boolean;
/** JMAP account the folder lives under (primary or a shared/group account). */
accountId?: string;
/** Display name of the owning account, used as the sidebar group header. */
accountName?: string;
/** True when the folder belongs to a shared/group account, not the user. */
isShared?: boolean;
}
export interface Thread {
@@ -284,6 +313,13 @@ export interface ContactCryptoKey {
contexts?: Record<string, boolean>;
}
export interface ContactDirectory {
uri: string;
kind?: 'directory' | 'entry' | string;
mediaType?: string;
pref?: number;
}
export interface ContactPronouns {
pronouns: string;
pref?: number;
@@ -318,14 +354,23 @@ export interface ContactCard {
relatedTo?: Record<string, ContactRelation>;
links?: Record<string, ContactLink>;
cryptoKeys?: Record<string, ContactCryptoKey>;
directories?: Record<string, ContactDirectory>;
keywords?: Record<string, boolean>;
members?: Record<string, boolean>;
speakToAs?: ContactSpeakToAs;
calendarUri?: string;
schedulingUri?: string;
freeBusyUri?: string;
source?: string;
prodId?: string;
created?: string;
updated?: string;
// Client-only metadata for cards that live in a shared / group account.
// Never sent to the server (api/contacts.ts strips them). `id` is
// namespaced as `<accountId>:<originalId>` for shared cards.
accountId?: string;
accountName?: string;
isShared?: boolean;
}
export interface AddressBookRights {
@@ -343,6 +388,15 @@ export interface AddressBook {
isSubscribed?: boolean;
sortOrder?: number;
myRights?: AddressBookRights;
// RFC 9610 share map: principal id → rights.
shareWith?: Record<string, AddressBookRights> | null;
// Client-only metadata for books that live in a shared / group account.
// `id` is namespaced as `<accountId>:<originalId>` for shared books so it
// cannot collide with the user's own "default" book.
originalId?: string;
accountId?: string;
accountName?: string;
isShared?: boolean;
}
export interface AddressBookWithCount extends AddressBook {
@@ -352,16 +406,45 @@ export interface AddressBookWithCount extends AddressBook {
// ─── Calendar (RFC 8984 JSCalendar) ─────────────────────
export interface Participant {
'@type'?: 'Participant';
name?: string;
email?: string;
sendTo?: Record<string, string>;
// Stalwart uses calendarAddress (a `mailto:` URI) instead of email/sendTo.
calendarAddress?: string;
kind?: string;
roles?: Record<string, boolean>;
participationStatus?: string;
participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string;
participationComment?: string;
// 'server' asks Stalwart to deliver the iTIP messages for this participant.
scheduleAgent?: 'server' | 'client' | 'none' | string;
scheduleStatus?: string[];
expectReply?: boolean;
description?: string;
}
// JSCalendar Location (RFC 8984 §4.2.5) — a physical place.
export interface EventLocation {
'@type'?: 'Location';
name?: string;
description?: string;
locationTypes?: Record<string, boolean>;
relativeTo?: string;
timeZone?: string;
coordinates?: string;
}
// JSCalendar VirtualLocation (RFC 8984 §4.2.6) — a video/online place.
export interface VirtualLocation {
'@type'?: 'VirtualLocation';
name?: string;
description?: string;
uri: string;
features?: Record<string, boolean>;
}
export interface RecurrenceRule {
'@type'?: 'RecurrenceRule';
frequency: string;
interval?: number;
until?: string;
@@ -376,11 +459,16 @@ export interface RecurrenceRule {
bySecond?: number[];
bySetPosition?: number[];
firstDayOfWeek?: string;
rscale?: string;
skip?: string;
}
export interface Alert {
trigger: { '@type': string; offset?: string; when?: string };
'@type'?: 'Alert';
trigger: { '@type': string; offset?: string; when?: string; relativeTo?: 'start' | 'end' | string };
action?: string;
// UTCDateTime the user acknowledged the alert; null/absent when pending.
acknowledged?: string | null;
}
export interface CalendarEvent {
@@ -392,34 +480,94 @@ export interface CalendarEvent {
description?: string;
start: string;
duration?: string;
timeZone?: string;
// `null` on write clears a field the server holds (JSON drops `undefined`,
// so an omitted key keeps the old value). All-day events carry a null zone.
timeZone?: string | null;
showWithoutTime?: boolean;
utcStart?: string;
utcEnd?: string;
status?: string;
freeBusyStatus?: string;
participants?: Record<string, Participant>;
recurrenceRules?: RecurrenceRule[];
recurrenceOverrides?: Record<string, Partial<CalendarEvent>>;
excludedRecurrenceRules?: RecurrenceRule[];
participants?: Record<string, Participant> | null;
// Scheduling (iTIP/iMIP). Stalwart derives the iCalendar ORGANIZER solely
// from `organizerCalendarAddress` (a `mailto:` URI); RFC 8984's `replyTo` is
// retired in jscalendarbis and only kept here so old values round-trip.
replyTo?: Record<string, string> | null;
organizerCalendarAddress?: string | null;
sequence?: number;
recurrenceRules?: RecurrenceRule[] | null;
recurrenceOverrides?: Record<string, Partial<CalendarEvent>> | null;
excludedRecurrenceRules?: RecurrenceRule[] | null;
recurrenceId?: string;
originalId?: string;
alerts?: Record<string, Alert>;
// Client-only: the event's server-side calendarIds (raw, unprefixed), kept
// when `calendarIds` is remapped to namespaced store ids for shared events.
originalCalendarIds?: Record<string, boolean>;
useDefaultAlerts?: boolean;
alerts?: Record<string, Alert> | null;
locations?: Record<string, EventLocation> | null;
virtualLocations?: Record<string, VirtualLocation> | null;
links?: Record<string, { href: string; rel?: string }>;
created?: string;
updated?: string;
progress?: string;
due?: string;
// Task-only (RFC 8984 JSTask) fields.
progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string;
progressUpdated?: string;
due?: string | null;
priority?: number;
percentComplete?: number;
color?: string;
// Client-only: account the event/occurrence belongs to (for shared/virtual).
localAccountId?: string;
// Client-only: JMAP account the event was fetched from. Absent for the
// primary account; set for events on calendars shared with the user so
// mutations can be routed to the owning account.
accountId?: string;
// Client-only: true when the event lives on a calendar shared with the user.
isShared?: boolean;
}
export interface CalendarRights {
mayReadFreeBusy?: boolean;
mayReadItems?: boolean;
mayWriteAll?: boolean;
mayWriteOwn?: boolean;
mayUpdatePrivate?: boolean;
mayRSVP?: boolean;
mayShare?: boolean;
mayDelete?: boolean;
}
export interface Calendar {
id: string;
name: string;
description?: string | null;
color?: string;
isVisible?: boolean;
isSubscribed?: boolean;
isDefault?: boolean;
sortOrder?: number;
myRights?: Record<string, boolean>;
myRights?: CalendarRights;
// Principals this (owned) calendar is shared with and their rights.
shareWith?: Record<string, CalendarRights> | null;
// Alerts applied to events that set `useDefaultAlerts` (RFC 8620 calendars).
defaultAlertsWithTime?: Record<string, Alert> | null;
defaultAlertsWithoutTime?: Record<string, Alert> | null;
// Client-only: JMAP account the calendar was fetched from. Absent for the
// primary account; set for calendars shared with the user.
accountId?: string;
// Client-only: the real JMAP id of a shared calendar, before its `id` was
// namespaced as `${accountId}:${id}` to stay unique in the store. Mutations
// and event queries must use this raw id against the owning account.
originalId?: string;
// Client-only: true when the calendar lives in another account (shared
// with the user by its owner).
isShared?: boolean;
// Client-only: set when `color` has been replaced by the viewer's local
// override for a shared calendar (see lib/calendar-utils). When true, the
// override wins over per-event colors so the whole shared calendar paints
// uniformly.
colorIsLocalOverride?: boolean;
}
// ─── Files (FileNode) ───────────────────────────────────
@@ -432,7 +580,43 @@ export interface FileNode {
blobId?: string | null;
size?: number;
created?: string;
updated?: string;
// Last modification time (UTCDate). Older builds asked for a non-existent
// `updated` property, which the server silently left undefined (#700).
modified?: string;
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
// principals capability and the properties are requested explicitly. A node
// is shared-out when `shareWith` has entries; `myRights` describes what the
// viewer may do (always full rights on owned nodes).
myRights?: FileNodeRights;
shareWith?: Record<string, FileNodeRights> | null;
// True when this node was fetched from another principal's account that was
// shared with the logged-in user ("Shared with me").
isShared?: boolean;
// Owning account's JMAP id and display name, set when aggregating nodes
// across accounts so blob downloads route to the right account.
accountId?: string;
accountName?: string;
}
// FileNode rights as defined by Stalwart's JmapSharedObject implementation.
export interface FileNodeRights {
mayRead: boolean;
mayAddChildren: boolean;
mayRename: boolean;
mayDelete: boolean;
mayModifyContent: boolean;
mayShare: boolean;
}
// RFC 9670 principal — an entity (user, group, resource) that files and
// calendars can be shared with. On Stalwart the principal id doubles as the
// account id of that user/group.
export interface Principal {
id: string;
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
name: string;
description?: string | null;
email?: string | null;
}
// ─── Push / SSE ─────────────────────────────────────────
@@ -452,6 +636,17 @@ export interface PushSubscription {
verificationCode?: string | null;
expires?: string | null;
types?: string[] | null;
emailPush?: Record<string, EmailPushConfig> | null;
}
// draft-ietf-jmap-emailpush EmailPushConfig: the server evaluates `filter`
// against every newly delivered message and only pushes when it matches, so a
// client can keep spam (Junk) out of its push channel server-side. Keyed by
// account id on PushSubscription.emailPush.
export interface EmailPushConfig {
filter: Record<string, unknown> | null;
properties: string[];
urgency?: 'very-low' | 'low' | 'normal' | 'high';
}
// ─── Capability URNs ────────────────────────────────────
@@ -466,4 +661,7 @@ export const CAPABILITIES = {
SIEVE: 'urn:ietf:params:jmap:sieve',
QUOTA: 'urn:ietf:params:jmap:quota',
FILES: 'urn:ietf:params:jmap:filenode',
PRINCIPALS: 'urn:ietf:params:jmap:principals',
PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner',
EMAIL_PUSH: 'urn:ietf:params:jmap:emailpush',
} as const;
+598
View File
@@ -0,0 +1,598 @@
import { CAPABILITIES } from './types';
import type { Email, JMAPSession, JMAPMethodCall, Mailbox } from './types';
import {
jmapClient, rewriteSessionUrl, extractOrigin, parseRetryAfter, REQUEST_TIMEOUT_MS,
type StoredCredentials,
} from './jmap-client';
import { keywordPointer, mailboxPointer } from './patch-pointer';
import { secureFetch } from '../lib/client-cert';
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
import { toWildcardQuery } from '../lib/search-utils';
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
// Unread / Starred). Because the JMAP client is a single-account singleton
// (one live session at a time), the *active* registry account goes through
// the live client while every other account is fetched "detached" — reading
// its stored credentials directly and posting to its own API URL — so the
// active session is never disturbed. Session discovery and the per-account
// mailbox lists are cached for the session so a refresh is one Email/query +
// Email/get per account instead of four round-trips.
const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
];
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
const SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
const MAILBOX_CACHE_TTL_MS = 60 * 1000;
export type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash';
export type CrossView = 'all' | 'unread' | 'starred';
/** Roles left out of the cross-account All mail / Unread / Starred views. */
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts',
]);
export interface UnifiedEmail extends Email {
/** Registry account id this message belongs to (not the JMAP account id). */
sourceAccountId: string;
/**
* JMAP account id the message lives under. Equals the registry account's
* primary JMAP account for own mail, or a group/shared owner account when
* the message came from a group inbox. Carried so opening/acting can target
* the right account via an Email/get or Email/set override.
*/
jmapAccountId: string;
/** True when this message belongs to a group/shared inbox, not the user's own. */
isShared: boolean;
/** Human label for the owning account when shared (e.g. "Support"). */
sharedLabel?: string;
/** Name of the folder the message was found in (cross views). */
sourceFolder?: string;
}
export interface UnifiedInboxResult {
emails: UnifiedEmail[];
/** accountId → error message for accounts that could not be fetched. */
errors: Record<string, string>;
/** Per-target paging cursors to pass back as `positions` for the next page. */
positions: Record<string, number>;
hasMore: boolean;
}
export interface UnifiedFetchOptions {
/** Per-role view (default inbox) — ignored when `view` is set. */
role?: UnifiedRole;
/** Cross-folder view over inbox + custom folders. */
view?: CrossView;
/** Also scan group/shared accounts reachable through each login. */
includeGroup?: boolean;
/** Free-text search ANDed into every account's query. */
query?: string;
/** Cursors from the previous page (`UnifiedInboxResult.positions`). */
positions?: Record<string, number>;
}
// ── Detached transport ──────────────────────────────────────────────────
function authHeaderFor(creds: StoredCredentials): string {
if (creds.accessToken) return `Bearer ${creds.accessToken}`;
return `Basic ${btoa(`${creds.username}:${creds.password}`)}`;
}
/** Thrown when a detached account's server rate-limited us (429). */
export class UnifiedRateLimitError extends Error {
retryAfterMs: number;
constructor(retryAfterMs: number) {
super('Rate limited');
this.name = 'UnifiedRateLimitError';
this.retryAfterMs = retryAfterMs;
}
}
/**
* `fetch` with the same deadline the live client applies
* (`jmapClient.authenticatedFetch`): an AbortController-backed timeout, 401
* → "Session expired", 429 → rate-limit error carrying Retry-After.
*/
export async function fetchWithDeadline(
url: string,
init: RequestInit,
timeoutMs = REQUEST_TIMEOUT_MS,
): Promise<Response> {
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timer = setTimeout(() => controller?.abort(), timeoutMs);
try {
const response = await secureFetch(url, { ...init, signal: controller?.signal });
if (response.status === 401) throw new Error('Session expired');
if (response.status === 429) {
const retryAfter = response.headers?.get?.('Retry-After') ?? null;
throw new UnifiedRateLimitError(parseRetryAfter(retryAfter));
}
if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`);
return response;
} catch (err) {
if ((err as Error)?.name === 'AbortError') throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
throw err;
} finally {
clearTimeout(timer);
}
}
type MethodResponses = Array<[string, Record<string, any>, string]>;
interface Transport {
post: (methodCalls: JMAPMethodCall[]) => Promise<MethodResponses>;
}
async function jmapPost(
apiUrl: string,
authHeader: string,
methodCalls: JMAPMethodCall[],
): Promise<MethodResponses> {
const response = await fetchWithDeadline(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({ using: [CAPABILITIES.CORE, CAPABILITIES.MAIL], methodCalls }),
});
const body = await response.json();
return (body.methodResponses ?? []) as MethodResponses;
}
function resolveJmapAccountId(session: JMAPSession): string | null {
return (
session.primaryAccounts?.[CAPABILITIES.MAIL] ||
session.primaryAccounts?.[CAPABILITIES.CORE] ||
Object.keys(session.accounts ?? {})[0] ||
null
);
}
// Same rule as JMAPClient.getSharedMailAccounts(): Stalwart doesn't always
// populate `accountCapabilities` on shared accounts, so an account counts as
// mail-capable when it advertises the mail capability OR is non-personal.
function sharedMailAccountsOf(session: JMAPSession, primaryId: string): { id: string; name: string }[] {
const out: { id: string; name: string }[] = [];
for (const [id, info] of Object.entries(session.accounts ?? {})) {
if (id === primaryId) continue;
const advertisesMail = info.accountCapabilities
? CAPABILITIES.MAIL in info.accountCapabilities
: false;
if (!advertisesMail && info.isPersonal) continue;
out.push({ id, name: info.name || id });
}
return out;
}
// Refresh an about-to-expire OAuth token and persist it so the next fetch (and
// a later account switch) reuse the fresh one. Returns possibly-updated creds.
async function ensureFreshCredentials(
accountId: string,
creds: StoredCredentials,
): Promise<StoredCredentials> {
if (
!creds.accessToken ||
!creds.refreshToken ||
!creds.tokenEndpoint ||
!creds.clientId ||
creds.expiresAt == null
) {
return creds;
}
if (creds.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return creds;
try {
const tokens: OAuthTokens = {
accessToken: creds.accessToken,
refreshToken: creds.refreshToken,
expiresAt: creds.expiresAt,
tokenEndpoint: creds.tokenEndpoint,
clientId: creds.clientId,
};
const next = await refreshOAuthAccessToken(tokens);
const updated: StoredCredentials = {
...creds,
accessToken: next.accessToken,
refreshToken: next.refreshToken ?? creds.refreshToken,
expiresAt: next.expiresAt,
tokenEndpoint: next.tokenEndpoint,
clientId: next.clientId,
};
const current = await jmapClient.getStoredCredentials(accountId);
if (!current || current.accessToken !== updated.accessToken) {
await jmapClient.setStoredCredentials(accountId, updated);
}
return updated;
} catch {
// Fall back to the existing (possibly expired) token; the request will
// surface a 401 which we report as an error for this account.
return creds;
}
}
// ── Per-account session + mailbox cache ────────────────────────────────
interface AccountEntry {
accountId: string;
transport: Transport;
primaryJmapId: string;
shared: { id: string; name: string }[];
/** Registry account served by the live jmapClient right now. */
live: boolean;
discoveredAt: number;
mailboxes: Map<string, { list: Mailbox[]; at: number }>;
}
const entries = new Map<string, AccountEntry>();
/** Forget every cached session/mailbox list (logout, credential change, tests). */
export function resetUnifiedCache(accountId?: string): void {
if (accountId) entries.delete(accountId);
else entries.clear();
}
function isLiveAccount(accountId: string): boolean {
if (!jmapClient.isConnected) return false;
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (!username || !serverUrl) return false;
// Same rule as generateAccountId(username, serverUrl) in lib/account-utils.
let host = serverUrl;
try { host = new URL(serverUrl).hostname; } catch { host = serverUrl.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); }
return `${username}@${host}` === accountId;
}
async function entryFor(accountId: string): Promise<AccountEntry> {
const live = isLiveAccount(accountId);
const cached = entries.get(accountId);
if (cached && cached.live === live && Date.now() - cached.discoveredAt < SESSION_CACHE_TTL_MS) {
return cached;
}
if (live && jmapClient.currentSession) {
const entry: AccountEntry = {
accountId,
live: true,
transport: { post: async (calls) => (await jmapClient.request(calls)).methodResponses as MethodResponses },
primaryJmapId: jmapClient.accountId,
shared: jmapClient.getSharedMailAccounts(),
discoveredAt: Date.now(),
mailboxes: cached?.mailboxes ?? new Map(),
};
entries.set(accountId, entry);
return entry;
}
let creds = await jmapClient.getStoredCredentials(accountId);
if (!creds) throw new Error('No stored credentials');
creds = await ensureFreshCredentials(accountId, creds);
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
const authHeader = authHeaderFor(creds);
// Session discovery.
const sessionRes = await fetchWithDeadline(`${baseUrl}/.well-known/jmap`, {
headers: { Authorization: authHeader, Accept: 'application/json' },
});
const session = (await sessionRes.json()) as JMAPSession;
// Point the advertised apiUrl at the origin we actually connected to
// (servers often self-report unreachable container-internal hosts) and
// resolve relative URLs — same as the live client does.
const apiUrl = rewriteSessionUrl(session.apiUrl, extractOrigin(baseUrl)) ?? session.apiUrl;
const primaryJmapId = resolveJmapAccountId(session);
if (!primaryJmapId) throw new Error('No mail account in session');
const entry: AccountEntry = {
accountId,
live: false,
transport: {
post: async (calls) => {
try {
return await jmapPost(apiUrl, authHeader, calls);
} catch (err) {
// A dead session must not be reused for the next refresh.
if (err instanceof Error && err.message === 'Session expired') entries.delete(accountId);
throw err;
}
},
},
primaryJmapId,
shared: sharedMailAccountsOf(session, primaryJmapId),
discoveredAt: Date.now(),
mailboxes: cached?.mailboxes ?? new Map(),
};
entries.set(accountId, entry);
return entry;
}
async function mailboxesFor(entry: AccountEntry, jmapAccountId: string, force = false): Promise<Mailbox[]> {
const cached = entry.mailboxes.get(jmapAccountId);
if (cached && !force && Date.now() - cached.at < MAILBOX_CACHE_TTL_MS) return cached.list;
const res = await entry.transport.post([
['Mailbox/get', {
accountId: jmapAccountId,
properties: ['id', 'role', 'name', 'parentId', 'unreadEmails', 'totalEmails', 'myRights'],
}, '0'],
]);
const [name, body] = res[0] ?? [];
if (name !== 'Mailbox/get') throw new Error(body?.description || 'Mailbox/get failed');
const list = ((body.list as Mailbox[]) ?? []).map((m) => ({ ...m, accountId: jmapAccountId }));
entry.mailboxes.set(jmapAccountId, { list, at: Date.now() });
return list;
}
/** Cached mailbox lists per JMAP account of one registry account (for counts). */
export function cachedUnifiedMailboxes(accountId: string): Array<{ jmapAccountId: string; mailboxes: Mailbox[] }> {
const entry = entries.get(accountId);
if (!entry) return [];
return [...entry.mailboxes.entries()].map(([jmapAccountId, v]) => ({ jmapAccountId, mailboxes: v.list }));
}
// ── Queries ─────────────────────────────────────────────────────────────
function findByRole(mailboxes: Mailbox[], role: UnifiedRole): Mailbox | undefined {
if (role === 'junk') return mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
return mailboxes.find((m) => m.role === role);
}
/** Folders of one JMAP account that feed the cross views: inbox + custom folders. */
export function crossIncludedMailboxes(mailboxes: Mailbox[]): Mailbox[] {
return mailboxes.filter((m) => !CROSS_EXCLUDED_ROLES.has(m.role ?? ''));
}
function buildFilter(
mailboxes: Mailbox[],
opts: UnifiedFetchOptions,
): Record<string, unknown> | null {
const conditions: Record<string, unknown>[] = [];
if (opts.view) {
const included = crossIncludedMailboxes(mailboxes);
if (included.length === 0) return null;
conditions.push(
included.length === 1
? { inMailbox: included[0].id }
: { operator: 'OR', conditions: included.map((m) => ({ inMailbox: m.id })) },
);
if (opts.view === 'unread') conditions.push({ notKeyword: '$seen' });
if (opts.view === 'starred') conditions.push({ hasKeyword: '$flagged' });
} else {
const target = findByRole(mailboxes, opts.role ?? 'inbox');
if (!target) return null;
conditions.push({ inMailbox: target.id });
}
const q = opts.query?.trim();
if (q) conditions.push({ text: toWildcardQuery(q) });
return conditions.length === 1 ? conditions[0] : { operator: 'AND', conditions };
}
function targetKey(accountId: string, jmapAccountId: string): string {
return `${accountId}|${jmapAccountId}`;
}
async function fetchTarget(
entry: AccountEntry,
target: { jmapId: string; isShared: boolean; label?: string },
opts: UnifiedFetchOptions,
position: number,
limit: number,
): Promise<{ emails: UnifiedEmail[]; hasMore: boolean }> {
const mailboxes = await mailboxesFor(entry, target.jmapId);
const filter = buildFilter(mailboxes, opts);
if (!filter) return { emails: [], hasMore: false };
const res = await entry.transport.post([
['Email/query', {
accountId: target.jmapId,
filter,
sort: [{ property: 'receivedAt', isAscending: false }],
position,
limit,
calculateTotal: true,
}, '0'],
['Email/get', {
accountId: target.jmapId,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: EMAIL_LIST_PROPERTIES,
}, '1'],
]);
const [qName, qBody] = res[0] ?? [];
if (qName !== 'Email/query') throw new Error(qBody?.description || 'Email/query failed');
const ids = (qBody.ids as string[]) ?? [];
const total = typeof qBody.total === 'number' ? qBody.total : undefined;
const list = ((res[1]?.[1]?.list as Email[]) ?? []);
const byId = new Map(list.map((e) => [e.id, e]));
const nameOf = (e: Email): string | undefined =>
mailboxes.find((m) => e.mailboxIds?.[m.id])?.name;
const emails = ids.flatMap((id) => {
const e = byId.get(id);
if (!e) return [];
return [{
...e,
sourceAccountId: entry.accountId,
jmapAccountId: target.jmapId,
isShared: target.isShared,
sharedLabel: target.isShared ? target.label : undefined,
sourceFolder: opts.view ? nameOf(e) : undefined,
} as UnifiedEmail];
});
const hasMore = total !== undefined ? position + ids.length < total : ids.length === limit;
return { emails, hasMore };
}
/**
* One page of the unified view over `accountIds`. Every target (a registry
* account's own mailbox plus, with `includeGroup`, each group/shared account
* reachable through it) is queried at its own `positions` cursor and the
* results are merged newest-first. Per-account failures are collected in
* `errors` while the others still show.
*/
export async function fetchUnifiedInbox(
accountIds: string[],
perAccountLimit = 25,
opts: UnifiedFetchOptions = {},
): Promise<UnifiedInboxResult> {
const { includeGroup = false } = opts;
const errors: Record<string, string> = {};
const positions: Record<string, number> = { ...(opts.positions ?? {}) };
let hasMore = false;
const settled = await Promise.all(
accountIds.map(async (accountId) => {
try {
const entry = await entryFor(accountId);
const targets: { jmapId: string; isShared: boolean; label?: string }[] = [
{ jmapId: entry.primaryJmapId, isShared: false },
];
if (includeGroup) {
for (const acc of entry.shared) targets.push({ jmapId: acc.id, isShared: true, label: acc.name });
}
const perTarget = await Promise.all(targets.map(async (target) => {
const key = targetKey(accountId, target.jmapId);
const position = positions[key] ?? 0;
try {
const page = await fetchTarget(entry, target, opts, position, perAccountLimit);
positions[key] = position + page.emails.length;
if (page.hasMore) hasMore = true;
return page.emails;
} catch (err) {
// A single inaccessible shared account shouldn't sink the whole
// account's view; skip it and keep the others. Own-mail failures
// are the account's failure.
if (!target.isShared) throw err;
return [] as UnifiedEmail[];
}
}));
return perTarget.flat();
} catch (err) {
errors[accountId] = err instanceof Error ? err.message : 'Failed to load';
return [] as UnifiedEmail[];
}
}),
);
const emails = settled
.flat()
.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
return { emails, errors, positions, hasMore };
}
// ── Actions routed to the owning account ────────────────────────────────
async function emailSet(
accountId: string,
jmapAccountId: string,
update: Record<string, Record<string, unknown>>,
destroy?: string[],
): Promise<void> {
const entry = await entryFor(accountId);
const args: Record<string, unknown> = { accountId: jmapAccountId };
if (Object.keys(update).length > 0) args.update = update;
if (destroy && destroy.length > 0) args.destroy = destroy;
const res = await entry.transport.post([['Email/set', args, '0']]);
const [name, body] = res[0] ?? [];
if (name !== 'Email/set') throw new Error(body?.description || 'Email/set failed');
const failures = { ...(body.notUpdated ?? {}), ...(body.notDestroyed ?? {}) } as Record<string, { type?: string; description?: string }>;
const first = Object.entries(failures)[0];
if (first) throw new Error(`${first[1]?.type ?? 'error'}${first[1]?.description ? `: ${first[1].description}` : ''}`);
}
function groupByOwner(emails: UnifiedEmail[]): Map<string, { accountId: string; jmapAccountId: string; emails: UnifiedEmail[] }> {
const groups = new Map<string, { accountId: string; jmapAccountId: string; emails: UnifiedEmail[] }>();
for (const e of emails) {
const key = targetKey(e.sourceAccountId, e.jmapAccountId);
const g = groups.get(key);
if (g) g.emails.push(e);
else groups.set(key, { accountId: e.sourceAccountId, jmapAccountId: e.jmapAccountId, emails: [e] });
}
return groups;
}
/** Patch keywords (`{ $seen: true, $flagged: null }`) on messages of any account. */
export async function patchUnifiedKeywords(
emails: UnifiedEmail[],
patch: Record<string, boolean | null>,
): Promise<void> {
const pointer: Record<string, unknown> = {};
for (const [k, v] of Object.entries(patch)) pointer[keywordPointer(k)] = v === false ? null : v;
for (const g of groupByOwner(emails).values()) {
const update: Record<string, Record<string, unknown>> = {};
for (const e of g.emails) update[e.id] = { ...pointer };
await emailSet(g.accountId, g.jmapAccountId, update);
}
}
export type UnifiedMoveTarget = 'archive' | 'trash' | 'junk' | 'inbox';
/**
* Move messages into the role folder of their *own* account (archive into
* the owner's Archive, …). Spam/not-spam also flip `$junk`/`$notjunk` (#850).
* Throws when an owning account lacks the target folder.
*/
export async function moveUnifiedEmails(
emails: UnifiedEmail[],
target: UnifiedMoveTarget,
opts: { markRead?: boolean } = {},
): Promise<void> {
for (const g of groupByOwner(emails).values()) {
const entry = await entryFor(g.accountId);
const mailboxes = await mailboxesFor(entry, g.jmapAccountId);
const dest = findByRole(mailboxes, target);
if (!dest) throw new Error(`No ${target} folder in this account`);
const update: Record<string, Record<string, unknown>> = {};
for (const e of g.emails) {
const patch: Record<string, unknown> = {};
for (const id of Object.keys(e.mailboxIds ?? {})) {
if (id !== dest.id) patch[mailboxPointer(id)] = null;
}
patch[mailboxPointer(dest.id)] = true;
if (target === 'junk') {
patch[keywordPointer('$junk')] = true;
patch[keywordPointer('$notjunk')] = null;
} else if (target === 'inbox') {
patch[keywordPointer('$junk')] = null;
patch[keywordPointer('$notjunk')] = true;
}
if (opts.markRead) patch[keywordPointer('$seen')] = true;
update[e.id] = patch;
}
await emailSet(g.accountId, g.jmapAccountId, update);
}
}
/**
* Delete like the folder list does: move to the owner's Trash, or destroy
* outright when the message already sits in Trash or `permanent` is set.
*/
export async function deleteUnifiedEmails(
emails: UnifiedEmail[],
opts: { permanent?: boolean; markRead?: boolean } = {},
): Promise<{ destroyed: number; trashed: number }> {
let destroyed = 0;
let trashed = 0;
for (const g of groupByOwner(emails).values()) {
const entry = await entryFor(g.accountId);
const mailboxes = await mailboxesFor(entry, g.jmapAccountId);
const trash = findByRole(mailboxes, 'trash');
const toDestroy = g.emails.filter((e) => opts.permanent || !trash || !!(trash && e.mailboxIds?.[trash.id]));
const toTrash = g.emails.filter((e) => !toDestroy.includes(e));
if (toDestroy.length > 0 && !trash && !opts.permanent) {
throw new Error('No Trash folder in this account');
}
if (toDestroy.length > 0) {
await emailSet(g.accountId, g.jmapAccountId, {}, toDestroy.map((e) => e.id));
destroyed += toDestroy.length;
}
if (toTrash.length > 0 && trash) {
await moveUnifiedEmails(toTrash, 'trash', { markRead: opts.markRead });
trashed += toTrash.length;
}
}
return { destroyed, trashed };
}
/** Whether a message sits in its account's Trash (drives the permanent-delete confirm). */
export function isInUnifiedTrash(email: UnifiedEmail): boolean {
const entry = entries.get(email.sourceAccountId);
const list = entry?.mailboxes.get(email.jmapAccountId)?.list ?? [];
const trash = findByRole(list, 'trash');
return !!trash && !!email.mailboxIds?.[trash.id];
}
+81 -14
View File
@@ -6,6 +6,11 @@ export interface ReleaseAsset {
size: number;
}
// Mirrors the webmail's version-check severities: `security` and `deprecated`
// releases show a non-dismissable banner. Read from a `severity: <value>` line
// in the release body (GitHub Releases has no structured field for it).
export type UpdateSeverity = 'normal' | 'security' | 'deprecated';
export interface LatestRelease {
tag: string;
name: string;
@@ -13,14 +18,39 @@ export interface LatestRelease {
publishedAt: string;
body: string;
apkAsset: ReleaseAsset | null;
// Lower-case hex SHA-256 of the APK, parsed from a `*.apk.sha256` companion
// asset or from the release body. Null when the release didn't publish one;
// the installer falls back to relying solely on Android's package signing
// check in that case (still strong, but less defense-in-depth).
// Companion `*.apk.sha256` asset, fetched lazily on Install (see
// resolveApkSha256) so the periodic check costs one request.
sha256Asset: ReleaseAsset | null;
// Lower-case hex SHA-256 of the APK when the release body lists one. Null
// when it doesn't; the installer then relies on the companion asset or,
// failing that, solely on Android's package signing check.
apkSha256: string | null;
severity: UpdateSeverity;
// Optional advisory link from an `advisory: <url>` line in the body.
advisoryUrl: string | null;
}
/** GitHub answered 403/429 (unauthenticated limit is 60 req/h/IP). */
export class UpdateRateLimitedError extends Error {
constructor(status: number) {
super(`GitHub API rate limited (${status})`);
this.name = 'UpdateRateLimitedError';
}
}
const SHA256_RE = /\b([a-f0-9]{64})\b/i;
const SEVERITY_RE = /^[ \t]*(?:[-*][ \t]*)?severity[ \t]*:[ \t]*(normal|security|deprecated)\b/im;
const ADVISORY_RE = /^[ \t]*(?:[-*][ \t]*)?advisory[ \t]*:[ \t]*(https?:\/\/\S+)/im;
export function parseSeverity(body: string): UpdateSeverity {
const m = SEVERITY_RE.exec(body || '');
return (m?.[1]?.toLowerCase() as UpdateSeverity | undefined) ?? 'normal';
}
export function parseAdvisoryUrl(body: string): string | null {
const m = ADVISORY_RE.exec(body || '');
return m ? m[1] : null;
}
function extractSha256(body: string, apkName: string | undefined): string | null {
if (!body) return null;
@@ -41,7 +71,7 @@ function extractSha256(body: string, apkName: string | undefined): string | null
return m ? m[1].toLowerCase() : null;
}
async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise<string | null> {
async function fetchCompanionSha256(asset: ReleaseAsset | null): Promise<string | null> {
if (!asset) return null;
try {
const res = await fetch(asset.browser_download_url);
@@ -54,12 +84,47 @@ async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise<st
}
}
/**
* The checksum to verify the APK against: the release body's hash when it
* lists one, else the companion `.sha256` asset (one extra request, made
* only when the user actually installs).
*/
export async function resolveApkSha256(release: LatestRelease): Promise<string | null> {
if (release.apkSha256) return release.apkSha256;
return fetchCompanionSha256(release.sha256Asset);
}
/**
* Turn GitHub release markdown into readable plain text: headings, emphasis,
* links (kept as their label), code spans and list bullets are unwrapped.
* Also strips the `severity:` / `advisory:` control lines.
*/
export function stripMarkdown(body: string): string {
return (body || '')
.replace(/\r\n/g, '\n')
.replace(SEVERITY_RE, '')
.replace(ADVISORY_RE, '')
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```\w*\n?/g, ''))
.replace(/^#{1,6}\s+/gm, '')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/(\*|_)(?=\S)(.*?)(?<=\S)\1/g, '$2')
.replace(/`([^`]+)`/g, '$1')
.replace(/^[ \t]*[-*+][ \t]+/gm, '• ')
.replace(/^[ \t]*>[ \t]?/gm, '')
.replace(/<\/?[a-z][^>]*>/gi, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { Accept: 'application/vnd.github+json' },
});
if (!res.ok) {
if (res.status === 404) return null;
if (res.status === 403 || res.status === 429) throw new UpdateRateLimitedError(res.status);
throw new Error(`GitHub API ${res.status}`);
}
const json = (await res.json()) as {
@@ -70,22 +135,24 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
body: string;
assets: ReleaseAsset[];
};
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
const assets = Array.isArray(json.assets) ? json.assets : [];
const apkAsset = assets.find((a) => a.name.endsWith('.apk')) ?? null;
const sha256Asset = apkAsset
? json.assets.find(
? assets.find(
(a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`,
)
: undefined;
const apkSha256 =
(await fetchCompanionSha256(sha256Asset)) ??
extractSha256(json.body || '', apkAsset?.name);
) ?? null
: null;
const body = json.body || '';
return {
tag: json.tag_name,
name: json.name || json.tag_name,
htmlUrl: json.html_url,
publishedAt: json.published_at,
body: json.body || '',
body,
apkAsset,
apkSha256,
sha256Asset,
apkSha256: extractSha256(body, apkAsset?.name),
severity: parseSeverity(body),
advisoryUrl: parseAdvisoryUrl(body),
};
}
+55 -9
View File
@@ -1,17 +1,29 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal } from 'react-native';
import { View, Text, StyleSheet, Modal, TextInput, type TextInputProps } from 'react-native';
import { AlertTriangle } from 'lucide-react-native';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
interface DialogInput {
value: string;
onChangeText: (value: string) => void;
placeholder?: string;
// Extra TextInput props (keyboardType, autoCapitalize, secureTextEntry…).
props?: Omit<TextInputProps, 'value' | 'onChangeText' | 'placeholder' | 'style'>;
}
interface DialogProps {
visible: boolean;
title: string;
message: string;
message?: string;
variant?: 'default' | 'destructive';
confirmText?: string;
cancelText?: string;
// Turns the confirm dialog into a prompt (webmail prompt-dialog.tsx).
input?: DialogInput;
confirmDisabled?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
@@ -30,13 +42,18 @@ export default function Dialog({
title,
message,
variant = 'default',
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText,
cancelText,
input,
confirmDisabled,
onConfirm,
onCancel,
}: DialogProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const confirmLabel = confirmText ?? t('common.confirm', 'Confirm');
const cancelLabel = cancelText ?? t('common.cancel', 'Cancel');
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.backdrop}>
@@ -47,15 +64,33 @@ export default function Dialog({
<AlertTriangle size={20} color={c.error} />
</View>
)}
<Text style={styles.title}>{title}</Text>
<Text style={styles.message}>{message}</Text>
<Text style={styles.title} accessibilityRole="header">{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
{input ? (
<TextInput
value={input.value}
onChangeText={input.onChangeText}
placeholder={input.placeholder}
placeholderTextColor={c.textMuted}
autoFocus
onSubmitEditing={confirmDisabled ? undefined : onConfirm}
accessibilityLabel={input.placeholder ?? title}
style={styles.input}
{...input.props}
/>
) : null}
</View>
<View style={styles.footer}>
<Button variant="outline" size="sm" onPress={onCancel}>
{cancelText}
{cancelLabel}
</Button>
<Button variant={variant === 'destructive' ? 'destructive' : 'default'} size="sm" onPress={onConfirm}>
{confirmText}
<Button
variant={variant === 'destructive' ? 'destructive' : 'default'}
size="sm"
onPress={onConfirm}
disabled={confirmDisabled}
>
{confirmLabel}
</Button>
</View>
</View>
@@ -107,6 +142,17 @@ function makeStyles(c: ThemePalette) {
color: c.mutedForeground, // text-muted-foreground
marginTop: spacing.sm,
},
input: {
...typography.body,
color: c.text,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: 10,
marginTop: spacing.md,
},
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
+572 -97
View File
@@ -1,49 +1,203 @@
import React from 'react';
import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
import type { Email } from '../api/types';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
import { jmapClient } from '../api/jmap-client';
import {
prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs,
} from '../lib/email-html';
import { pickEmailBody, selectRenderableHtml } from '../lib/email-body';
import { buildQuoteCollapseScript, collapsePlainTextQuotes } from '../lib/quote-collapse';
import { parseMailtoUrl } from '../lib/unsubscribe';
import { fetchInlineImageDataUri } from '../lib/email-export';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import type { RootStackParamList } from '../navigation/types';
// Largest inline image we'll pull inline as a data: URI. Anything bigger gets
// skipped so we don't freeze the JS thread base64-encoding megabytes.
const MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024;
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
}
return global.btoa ? global.btoa(binary) : btoa(binary);
}
type Nav = NativeStackNavigationProp<RootStackParamList>;
interface EmailBodyViewProps {
email: Email;
senderEmail?: string;
/** Owning account when the message lives in a shared/group mailbox. */
jmapAccountId?: string;
// The body is a native WebView, which on Android swallows horizontal touches
// before the surrounding pager can see them. We detect a clear horizontal
// swipe inside the page and report it here so the pager can change messages.
onSwipe?: (direction: 'prev' | 'next') => void;
// Pinch-zoom state from inside the page: `pinching` while two fingers are
// down, `zoomed` while the content is scaled in. The hosts use it to freeze
// the surrounding vertical scroll / horizontal pager so a zoom gesture can't
// scroll the pane away or switch mails.
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
/** Per-message override of the light/dark rendering (More sheet toggle). */
themeOverride?: 'light' | 'dark' | null;
/**
* Replace the message's own body - used when the outer message is an empty
* envelope around a message/rfc822 attachment or a TNEF winmail.dat and the
* real content was extracted client-side.
*/
bodyOverride?: { html?: string | null; text?: string | null } | null;
}
function extractHtmlBody(email: Email): string | null {
for (const part of email.htmlBody ?? []) {
const v = part.partId ? email.bodyValues?.[part.partId]?.value : undefined;
if (v) return v;
}
return null;
}
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
// heart -> cyan) and stylesheet-defined background images, which the static CSS
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
// those so they keep their original colors. Only injected when inversion is on.
const DARK_REINVERT_SCRIPT = `
(function () {
if (document.__rnDarkInvertDone) return;
var doc = document;
var win = window;
if (!doc.body) return;
document.__rnDarkInvertDone = true;
function extractTextBody(email: Email): string | null {
for (const part of email.textBody ?? []) {
const v = part.partId ? email.bodyValues?.[part.partId]?.value : undefined;
if (v) return v;
// Re-invert elements with stylesheet-defined background images (CSS attribute
// selectors only catch inline styles, not rules that survive in <style>).
doc.body.querySelectorAll('*').forEach(function (el) {
// Skip elements already handled by CSS attribute selectors.
if (el.style.backgroundImage || el.style.background ||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
// Skip leaf media elements (already re-inverted by CSS).
var tag = el.tagName;
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
var computed = win.getComputedStyle(el);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Re-invert so the background image returns to its original
// appearance. Matches the CSS background-image rules: re-invert even
// when media is nested, then cancel the per-image re-invert on that
// media so it isn't left double-inverted.
el.style.filter = 'invert(1) hue-rotate(180deg)';
el.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
.forEach(function (m) { m.style.filter = 'none'; });
}
});
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
// run in a span that re-inverts. Only act when the ancestor invert depth is
// odd - emojis inside a double-inverted bgcolor container already render at
// their original colors.
var emojiRe;
try {
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
} catch (e) {
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
}
return null;
}
var emojiTestRe = /\\p{Extended_Pictographic}/u;
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
function isOddInvertDepth(start) {
var count = 0;
var n = start;
while (n) {
if (n === doc.body) { count++; break; }
var cs = win.getComputedStyle(n);
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
n = n.parentElement;
}
return count % 2 === 1;
}
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function (node) {
var p = node.parentElement;
while (p) {
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return emojiTestRe.test(node.nodeValue || '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
var emojiTextNodes = [];
var cur;
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
emojiTextNodes.forEach(function (textNode) {
var parent = textNode.parentElement;
if (!parent || !isOddInvertDepth(parent)) return;
var text = textNode.nodeValue || '';
emojiRe.lastIndex = 0;
var frag = doc.createDocumentFragment();
var lastIndex = 0;
var m;
while ((m = emojiRe.exec(text)) !== null) {
if (m.index > lastIndex) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
}
var span = doc.createElement('span');
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
span.textContent = m[0];
frag.appendChild(span);
lastIndex = m.index + m[0].length;
}
if (lastIndex === 0) return;
if (lastIndex < text.length) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
}
parent.replaceChild(frag, textNode);
});
})();
`;
// Image fix-ups, mirroring the webmail's post-render passes:
// - collapse empty <td>/<div> wrappers left behind by blocked images so a
// blocked newsletter doesn't show rows of empty cells;
// - hide images that fail to load (dead/unreachable URLs) rather than leave
// the engine's broken-image glyph and alt text as stray labels.
const IMG_FIXUP_SCRIPT = `
(function () {
if (document.__rnImgFixupDone) return;
var doc = document;
if (!doc.body) return;
document.__rnImgFixupDone = true;
doc.querySelectorAll('img[data-blocked-src]').forEach(function (img) {
var el = img.parentElement;
while (el && el !== doc.body) {
if (el.tagName === 'TD' || el.tagName === 'TH' ||
(el.tagName === 'DIV' && el.parentElement && el.parentElement.tagName === 'TD')) {
var hasText = (el.textContent || '').replace(/[\\s\\u00A0]+/g, '').trim();
var hasMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
var hasLinks = el.querySelector('a[href]');
if (!hasText && !hasMedia && !hasLinks) {
el.setAttribute('data-blocked-collapsed-style', el.style.cssText);
el.style.display = 'none';
el.style.height = '0';
el.style.padding = '0';
el.style.overflow = 'hidden';
}
break;
}
if (el.tagName === 'TABLE' || el.tagName === 'TR') break;
el = el.parentElement;
}
});
function hideIfBroken(img) {
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) {
img.style.display = 'none';
}
}
doc.querySelectorAll('img').forEach(function (img) {
if (img.hasAttribute('data-blocked-src')) return;
if (img.complete) {
hideIfBroken(img);
} else {
img.addEventListener('error', function () { img.style.display = 'none'; }, { once: true });
img.addEventListener('load', function () { hideIfBroken(img); }, { once: true });
}
});
})();
`;
const HEIGHT_REPORTER = `
(function () {
@@ -51,23 +205,82 @@ const HEIGHT_REPORTER = `
// body.scrollHeight on Android WebView is pinned to the container viewport
// height, so we can't trust it. A dedicated wrapper gives a deterministic
// bounding box.
var wrapperEl = null;
function ensureWrapper() {
var b = document.body;
if (!b) return null;
if (wrapperEl && wrapperEl.isConnected) return wrapperEl;
var wrap = document.getElementById('__rn_email_body_wrap__');
if (!wrap) {
wrap = document.createElement('div');
wrap.id = '__rn_email_body_wrap__';
wrap.style.display = 'block';
wrap.style.width = '100%';
wrap.style.transformOrigin = 'top left';
while (b.firstChild) wrap.appendChild(b.firstChild);
b.appendChild(wrap);
}
wrapperEl = wrap;
return wrap;
}
// The width available for the email content (body content-box).
function availWidth() {
var b = document.body;
var cs = window.getComputedStyle(b);
var padL = parseFloat(cs.paddingLeft) || 0;
var padR = parseFloat(cs.paddingRight) || 0;
return b.clientWidth - padL - padR;
}
// Shared zoom state, mutated by the pinch-zoom handler injected after this
// script. "fit" is the shrink-to-fit scale, "scale" the user's pinch factor
// on top of it, "tx" pans the zoomed overflow into view horizontally.
// Vertical panning needs no state here: the height reports below carry the
// scaled height, so the surrounding native ScrollView grows and scrolls.
var Z = window.__rnZoom = window.__rnZoom || {
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
};
// Apply the combined fit × pinch transform using the cached layout numbers
// (cheap — no reflow), clamping the pan so content edges pin to the viewport.
function applyTransform(w) {
var s = Z.fit * Z.scale;
if (Z.scale > 1.001 || Z.fit < 0.999) {
var minTx = Math.min(0, Z.avail - Z.layoutW * s);
if (Z.tx < minTx) Z.tx = minTx;
if (Z.tx > 0) Z.tx = 0;
w.style.width = Z.layoutW + 'px';
w.style.transform = 'translateX(' + Z.tx + 'px) scale(' + s + ')';
} else {
Z.tx = 0;
w.style.width = '100%';
w.style.transform = 'none';
}
}
// Shrink-to-fit: emails authored at a fixed width (e.g. 600px tables) overflow
// a phone viewport and force horizontal scrolling. Lay the content out at its
// natural width, then scale the whole block down so its widest element fits
// the screen — the way native mail clients do. No sideways scrolling.
function fitToWidth(w) {
if (!w) return;
var avail = availWidth();
if (avail <= 0) return;
// Reset to full width so wrapping content reports its true overflow extent
// (the widest unbreakable element) rather than a previously-scaled box.
w.style.width = '100%';
var content = w.scrollWidth;
Z.avail = avail;
Z.layoutW = content > avail + 1 ? content : avail;
Z.fit = avail / Z.layoutW;
applyTransform(w);
}
function measure() {
var w = ensureWrapper();
if (!w) return 0;
// While a pinch is in flight only re-apply the transform — a full re-fit
// resets the wrapper width and would thrash layout on every frame.
if (Z.pinching) applyTransform(w);
else fitToWidth(w);
// getBoundingClientRect reflects the applied transform, so r.height is the
// already-scaled visual height of the content.
var r = w.getBoundingClientRect();
var b = document.body;
var cs = window.getComputedStyle(b);
@@ -117,7 +330,16 @@ const HEIGHT_REPORTER = `
if (document.body) ro.observe(document.body);
} catch (e) {}
try {
var mo = new MutationObserver(report);
var mo = new MutationObserver(function (records) {
for (var i = 0; i < records.length; i++) {
// Ignore the style mutations fitToWidth() applies to the wrapper itself,
// otherwise scaling would re-trigger a measure and loop indefinitely.
var rec = records[i];
if (rec.target === wrapperEl && rec.type === 'attributes') continue;
report();
return;
}
});
if (document.body) mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
} catch (e) {}
try {
@@ -126,29 +348,195 @@ const HEIGHT_REPORTER = `
img.addEventListener('error', report);
});
} catch (e) {}
// Hooks for the pinch-zoom handler script.
window.__rnApplyZoom = function () { var w = ensureWrapper(); if (w) applyTransform(w); };
window.__rnReport = report;
true;
})();
`;
export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps) {
// Detect a clear horizontal flick inside the WebView and report it. Uses
// capture-phase, passive listeners so it never blocks vertical scrolling, text
// selection, or link taps — it only reacts to a quick, horizontal-dominant
// gesture on release. Guarded so re-injection (onLoadEnd) is a no-op.
const SWIPE_DETECTOR = `
(function () {
if (window.__rnSwipeInit) return;
window.__rnSwipeInit = true;
var x0 = 0, y0 = 0, t0 = 0, tracking = false;
document.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { tracking = false; return; }
var t = e.touches[0];
x0 = t.clientX; y0 = t.clientY; t0 = Date.now(); tracking = true;
}, { passive: true, capture: true });
document.addEventListener('touchend', function (e) {
if (!tracking) return;
tracking = false;
// While the content is pinch-zoomed, horizontal flicks pan the zoomed
// content — never switch mails.
if (window.__rnZoom && (window.__rnZoom.pinching || window.__rnZoom.scale > 1.001)) return;
var t = e.changedTouches[0];
if (!t) return;
var dx = t.clientX - x0, dy = t.clientY - y0, dt = Date.now() - t0;
if (dt < 700 && Math.abs(dx) > 55 && Math.abs(dx) > Math.abs(dy) * 1.8) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'swipe', dir: dx < 0 ? 'next' : 'prev' }));
}
}
}, { passive: true, capture: true });
true;
})();
`;
// Pinch zoom + pan for the email content, driven entirely in the page: native
// WebView zoom can't work here because the WebView is auto-height inside a
// native ScrollView (viewport meta pins it to scale 1). A two-finger pinch
// scales the measured wrapper (on top of the shrink-to-fit scale the height
// reporter maintains) anchored at the fingers' horizontal midpoint; while
// zoomed, one-finger horizontal drags pan via translateX and vertical drags
// still scroll the surrounding ScrollView, which sees the scaled height
// through the regular height reports. Pinch moves call preventDefault so the
// engine consumes the gesture and Android parents don't intercept it; `zoom`
// messages let the RN side freeze the pane scroll / pager as a second guard.
const PINCH_ZOOM = `
(function () {
if (window.__rnPinchInit) return;
window.__rnPinchInit = true;
var Z = window.__rnZoom = window.__rnZoom || {
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
};
var MAX_ZOOM = 4;
var pinch = null; // { d0, s0, cx0, tx0 }
var pan = null; // { x0, tx0 }
var rafPending = false;
function post(msg) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
}
}
function apply() {
if (window.__rnApplyZoom) window.__rnApplyZoom();
}
// Height reports are rAF-throttled during the gesture so the RN container
// keeps up with the growing content without re-measuring on every move.
function scheduleReport() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(function () {
rafPending = false;
if (window.__rnReport) window.__rnReport();
});
}
function dist(t0, t1) {
var dx = t1.clientX - t0.clientX, dy = t1.clientY - t0.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
document.addEventListener('touchstart', function (e) {
if (e.touches.length === 2) {
var t0 = e.touches[0], t1 = e.touches[1];
pinch = {
d0: dist(t0, t1) || 1,
s0: Z.scale,
cx0: (t0.clientX + t1.clientX) / 2,
tx0: Z.tx,
};
pan = null;
if (!Z.pinching) {
Z.pinching = true;
post({ type: 'zoom', pinching: true, scale: Z.scale });
}
e.preventDefault();
} else if (e.touches.length === 1 && Z.scale > 1.001) {
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
} else {
pan = null;
}
}, { passive: false, capture: true });
document.addEventListener('touchmove', function (e) {
if (pinch && e.touches.length === 2) {
e.preventDefault();
var t0 = e.touches[0], t1 = e.touches[1];
var z = pinch.s0 * (dist(t0, t1) / pinch.d0);
if (z < 1) z = 1;
if (z > MAX_ZOOM) z = MAX_ZOOM;
// Keep the content under the fingers' midpoint stationary horizontally:
// the wrapper-local point that started under cx0 must map back to the
// current midpoint at the new scale. (applyTransform clamps the result.)
var cx = (t0.clientX + t1.clientX) / 2;
var local = (pinch.cx0 - pinch.tx0) / (Z.fit * pinch.s0);
Z.scale = z;
Z.tx = cx - local * (Z.fit * z);
apply();
scheduleReport();
} else if (pan && e.touches.length === 1 && Z.scale > 1.001) {
// No preventDefault: vertical-dominant drags must keep scrolling the
// native ScrollView around the WebView. Horizontal-dominant drags stay
// inside the WebView and pan here.
Z.tx = pan.tx0 + (e.touches[0].clientX - pan.x0);
apply();
}
}, { passive: false, capture: true });
function onTouchEnd(e) {
if (pinch && e.touches.length < 2) {
pinch = null;
// Snap back to the fitted layout when the pinch ends near scale 1.
if (Z.scale < 1.02) { Z.scale = 1; Z.tx = 0; }
Z.pinching = false;
apply();
if (window.__rnReport) window.__rnReport();
post({ type: 'zoom', pinching: false, scale: Z.scale });
// A finger left on screen continues as a pan.
if (e.touches.length === 1 && Z.scale > 1.001) {
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
}
}
if (e.touches.length === 0) pan = null;
}
document.addEventListener('touchend', onTouchEnd, { passive: true, capture: true });
document.addEventListener('touchcancel', onTouchEnd, { passive: true, capture: true });
true;
})();
`;
export default function EmailBodyView({
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride,
}: EmailBodyViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<Nav>();
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
const contacts = useContactsStore((s) => s.contacts);
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
const addToTrustedSendersBook = useContactsStore((s) => s.addToTrustedSendersBook);
const resolvedTheme = useResolvedTheme();
// Passively load the dedicated "Trusted Senders" address book once so its
// entries (synced across devices) feed the external-content trust check. We
// do not create the book here — it is created lazily when a sender is trusted.
React.useEffect(() => {
if (!trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [trustedSendersLoaded, loadTrustedSendersBook]);
// Index the address book once per render so the trusted-sender check below
// is O(1) per email. Only used when the matching setting is on.
const addressBookEmails = React.useMemo(() => {
if (!trustedSendersAddressBook) return null;
const out = new Set<string>();
for (const c of contacts) {
if (!c.emails) continue;
for (const e of Object.values(c.emails)) {
for (const card of contacts) {
if (!card.emails) continue;
for (const e of Object.values(card.emails)) {
if (e?.address) out.add(e.address.toLowerCase());
}
}
@@ -156,20 +544,24 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
}, [contacts, trustedSendersAddressBook]);
// Most marketing email is authored against a white background, so dark-mode
// inversion can wreck logos/banners. With this flag the user opts to render
// emails on a light surface even while the rest of the app is dark.
const renderAsDark = !emailAlwaysLightMode && resolvedTheme === 'dark';
// emails on a light surface even while the rest of the app is dark; the
// per-message toggle in the More sheet overrides both.
const renderAsDark = themeOverride
? themeOverride === 'dark'
: !emailAlwaysLightMode && resolvedTheme === 'dark';
const html = React.useMemo(() => extractHtmlBody(email), [email]);
const text = React.useMemo(() => extractTextBody(email), [email]);
// Prefer textBody when the HTML is a minimal auto-generated wrapper that would
// collapse newlines (mirrors webmail's hasMeaningfulHtmlBody fallback).
const rawHtml = React.useMemo(
() => (html && (!text || hasMeaningfulHtmlBody(html)) ? html : null),
[html, text],
);
const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false;
// RFC 8621 §4.1.4: an HTML-only message exposes the same part in textBody
// and htmlBody, so the text part is only an alternative when its partId
// differs (native #46). `pickEmailBody` routes by part type and id.
const picked = React.useMemo(() => {
if (bodyOverride) return { html: bodyOverride.html ?? null, text: bodyOverride.text ?? null };
return pickEmailBody(email);
}, [email, bodyOverride]);
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
const text = picked.text;
const trusted = senderEmail
? isSenderTrusted(senderEmail)
|| trustedSenderEmails.includes(senderEmail.toLowerCase())
|| (addressBookEmails?.has(senderEmail.toLowerCase()) ?? false)
: false;
@@ -179,14 +571,12 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
setAllowOnce(false);
}, [email.id]);
// Policy-driven gate (like the webmail's `externalBlocked`): the strict CSP
// is in effect whenever the policy is not "allow" and the sender is not
// trusted, regardless of what the detection pass finds - the pass only
// drives the banner and the placeholder swap.
const shouldBlock =
hasRemote &&
!trusted &&
!allowOnce &&
externalContentPolicy !== 'allow';
const showBanner =
hasRemote &&
!!rawHtml &&
!trusted &&
!allowOnce &&
externalContentPolicy !== 'allow';
@@ -212,8 +602,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
}
const toFetch = refs
.map((ref) => ({ ref, att: byCid.get(ref) }))
.filter((x): x is { ref: string; att: NonNullable<ReturnType<typeof byCid.get>> } => !!x.att)
.filter(({ att }) => !att.size || att.size <= MAX_INLINE_IMAGE_BYTES);
.filter((x): x is { ref: string; att: NonNullable<ReturnType<typeof byCid.get>> } => !!x.att);
if (toFetch.length === 0) return;
let cancelled = false;
@@ -222,10 +611,9 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
await Promise.all(
toFetch.map(async ({ ref, att }) => {
try {
const buf = await jmapClient.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
if (cancelled) return;
const b64 = arrayBufferToBase64(buf);
next[ref] = `data:${att.type || 'application/octet-stream'};base64,${b64}`;
const uri = await fetchInlineImageDataUri(att, jmapAccountId);
if (cancelled || !uri) return;
next[ref] = uri;
} catch (err) {
console.warn('[EmailBodyView] cid fetch failed', ref, err);
}
@@ -236,33 +624,74 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
}
})();
return () => { cancelled = true; };
}, [rawHtml, email.attachments]);
}, [rawHtml, email.attachments, jmapAccountId]);
const source = React.useMemo(() => {
const quoteLabels = React.useMemo(() => ({
show: t('email_viewer.show_quoted_text', 'Show quoted text'),
hide: t('email_viewer.hide_quoted_text', 'Hide quoted text'),
}), [t]);
const prepared = React.useMemo(() => {
if (rawHtml) {
return {
html: wrapEmailHtml(rawHtml, {
blockRemoteImages: shouldBlock,
cidMap,
isDark: renderAsDark,
}),
};
const res = prepareEmailHtml(rawHtml, {
blockRemoteImages: shouldBlock,
cidMap,
isDark: renderAsDark,
messageSpacing,
});
return { ...res, isHtml: true };
}
const fallbackText = text ?? email.preview ?? '';
if (!fallbackText) {
return {
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>', {
isDark: renderAsDark,
}),
};
const res = prepareEmailHtml(
`<em style="color:#71717a">${t('email_viewer.no_body_content', '(No body content available)')}</em>`,
{ isDark: renderAsDark },
);
return { ...res, isHtml: true };
}
const safe = collapsePlainTextQuotes(plainTextToSafeHtml(fallbackText), quoteLabels);
return {
html: wrapPlainTextEmail(plainTextToSafeHtml(fallbackText), { isDark: renderAsDark }),
html: wrapPlainTextEmail(safe, { isDark: renderAsDark, font: plainTextFont }),
applyInversion: false,
hasNativeDark: false,
blockedExternal: false,
isHtml: false,
};
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]);
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark, messageSpacing, plainTextFont, quoteLabels, t]);
const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]);
const showBanner = shouldBlock && prepared.blockedExternal;
// Inversion is applied for HTML bodies in dark mode unless the email ships
// its own dark-mode CSS - decided once by prepareEmailHtml on the same
// input the CSS was built from, so the DOM pass and the stylesheet agree.
const injectedJs = React.useMemo(
() =>
(prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
[prepared.isHtml, prepared.applyInversion, quoteLabels],
);
const onMessage = (e: WebViewMessageEvent) => {
const parsed = parseInt(e.nativeEvent.data, 10);
const data = e.nativeEvent.data;
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
if (data.charCodeAt(0) === 123 /* '{' */) {
try {
const msg = JSON.parse(data);
if (msg.type === 'swipe' && (msg.dir === 'prev' || msg.dir === 'next')) {
onSwipe?.(msg.dir);
} else if (msg.type === 'zoom') {
onZoomChange?.({
pinching: !!msg.pinching,
zoomed: typeof msg.scale === 'number' && msg.scale > 1.01,
});
}
} catch { /* ignore malformed */ }
return;
}
const parsed = parseInt(data, 10);
if (Number.isNaN(parsed) || parsed <= 0) return;
setHeight((prev) => (Math.abs(parsed - prev) < 2 ? prev : parsed));
};
@@ -270,27 +699,52 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
const onLoadImages = () => setAllowOnce(true);
const onTrustSender = () => {
if (senderEmail) {
// Keep the local allow-list for instant effect. The server-side "Trusted
// Senders" address book (synced across devices) is only written when
// the user opted into it - otherwise a book would silently appear in
// their contacts (matches the webmail).
addTrustedSender(senderEmail);
if (trustedSendersAddressBook) {
void addToTrustedSendersBook(senderEmail).catch(() => {});
}
setAllowOnce(true);
}
};
// mailto: links inside the body open the app's own composer with the parsed
// recipients/subject/body rather than the OS mail handler (webmail 1.8.1).
const openMailto = (url: string) => {
const fields = parseMailtoUrl(url);
if (!fields) return;
navigation.navigate('Compose', {
prefillTo: fields.to.map((address) => ({ email: address })),
prefillCc: fields.cc?.map((address) => ({ email: address })),
prefillSubject: fields.subject,
prefillBody: fields.body,
});
};
return (
<View style={styles.wrapper}>
{showBanner && (
<View style={styles.banner}>
{externalContentPolicy === 'ask' && (
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
<ImageIcon size={14} color={c.textSecondary} />
<Text style={styles.bannerButtonText}>Load external content</Text>
</Pressable>
)}
{senderEmail ? (
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
<ShieldCheck size={14} color={c.textSecondary} />
<Text style={styles.bannerButtonText}>Trust sender</Text>
</Pressable>
) : null}
<Text style={styles.bannerText}>
{t('email_viewer.external_content_warning', 'Images and external content have been blocked')}
</Text>
<View style={styles.bannerActions}>
{externalContentPolicy === 'ask' && (
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
<ImageIcon size={14} color={c.textSecondary} />
<Text style={styles.bannerButtonText}>{t('email_viewer.load_external_content', 'Load images')}</Text>
</Pressable>
)}
{senderEmail ? (
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
<ShieldCheck size={14} color={c.textSecondary} />
<Text style={styles.bannerButtonText}>{t('email_viewer.trust_sender', 'Always trust this sender')}</Text>
</Pressable>
) : null}
</View>
</View>
)}
@@ -300,16 +754,25 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
originWhitelist={['about:blank']}
source={source}
javaScriptEnabled
injectedJavaScript={HEIGHT_REPORTER}
injectedJavaScript={injectedJs}
onMessage={onMessage}
onLoadEnd={() => {
// A (re)load resets the page's zoom state to 1 — drop any host-side
// scroll locks so they can't go stale (e.g. cid images arriving
// reload the source mid-zoom).
onZoomChange?.({ pinching: false, zoomed: false });
// Re-inject after load to cover Android cases where
// `injectedJavaScript` runs too early to see the final layout.
// `injectedJavaScript` runs too early to see the final layout. Every
// pass guards itself so running it again here is a no-op once the
// body has been processed.
setTimeout(() => {
webviewRef.current?.injectJavaScript(HEIGHT_REPORTER);
webviewRef.current?.injectJavaScript(injectedJs);
}, 50);
}}
scrollEnabled={false}
// Native Android zoom would scale the viewport inside the fixed-size
// container; pinch zoom is implemented in the page instead.
setBuiltInZoomControls={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
setSupportMultipleWindows={false}
@@ -328,10 +791,15 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
containerStyle={styles.webviewContainer}
onShouldStartLoadWithRequest={(request) => {
const url = request.url;
if (!url || url === 'about:blank' || url.startsWith('data:')) {
return true;
// Only the initial about:blank document may load. A `data:` (or
// any other) navigation would replace the email body with no way
// back, so it is refused; images are reachable via the chips.
if (!url || url === 'about:blank') return true;
if (/^mailto:/i.test(url)) {
openMailto(url);
return false;
}
if (/^(https?|mailto|tel|sms):/i.test(url)) {
if (/^(https?|tel|sms):/i.test(url)) {
void Linking.openURL(url).catch(() => undefined);
}
return false;
@@ -346,16 +814,23 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrapper: { width: '100%' },
banner: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: spacing.md,
gap: spacing.xs,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: c.surfaceHover,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
bannerText: {
...typography.caption,
color: c.textMuted,
},
bannerActions: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: spacing.md,
},
bannerButton: {
flexDirection: 'row',
alignItems: 'center',
+205
View File
@@ -0,0 +1,205 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Mail } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Identity } from '../api/types';
import { useLocaleStore } from '../stores/locale-store';
interface IdentitySheetProps {
visible: boolean;
onClose: () => void;
identities: Identity[];
selectedIdentityId: string | null;
onPick: (identity: Identity) => void;
}
export function IdentitySheet({
visible, onClose, identities, selectedIdentityId, onPick,
}: IdentitySheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
const { t } = useLocaleStore();
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView style={styles.scrollList}>
{identities.map((identity) => {
const isSelected = identity.id === selectedIdentityId;
return (
<Pressable
key={identity.id}
onPress={() => {
onPick(identity);
onClose();
}}
style={({ pressed }) => [
styles.identityRow,
pressed && styles.identityRowPressed,
]}
>
<View style={styles.iconWrap}>
<Mail size={16} color={isSelected ? c.primary : c.textSecondary} />
</View>
<View style={styles.identityDetails}>
{!!identity.name && (
<Text style={styles.identityName} numberOfLines={1}>
{identity.name}
</Text>
)}
<Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}>
{identity.email}
</Text>
</View>
{isSelected && (
<View style={styles.checkWrap}>
<Check size={16} color={c.primary} />
</View>
)}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
sheetOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
sheetOverlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '60%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
sheetHandleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
paddingBottom: spacing.sm,
},
sheetHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: c.border,
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
sheetTitle: {
...typography.bodySemibold,
color: c.text,
},
sheetClose: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.xs,
},
scrollList: {
paddingVertical: spacing.xs,
},
identityRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
minHeight: 52,
},
identityRowPressed: {
backgroundColor: c.surfaceHover,
},
iconWrap: {
marginRight: spacing.md,
width: 28,
height: 28,
borderRadius: radius.xs,
backgroundColor: c.surface,
alignItems: 'center',
justifyContent: 'center',
},
identityDetails: {
flex: 1,
justifyContent: 'center',
},
identityName: {
...typography.bodyMedium,
color: c.text,
},
identityEmail: {
...typography.caption,
color: c.textSecondary,
marginTop: 2,
},
identityEmailOnly: {
...typography.bodyMedium,
color: c.text,
marginTop: 0,
},
checkWrap: {
marginLeft: spacing.md,
},
});
}
+10 -3
View File
@@ -11,6 +11,8 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors } from '../theme/colors';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { localizeMailboxName } from '../lib/mailbox-label';
import type { Mailbox } from '../api/types';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
@@ -38,6 +40,7 @@ export function MoveSheet({
}: MoveSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
@@ -88,7 +91,7 @@ export function MoveSheet({
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text>
<Text style={styles.sheetTitle}>{t('context_menu.move_to', 'Move to folder')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
@@ -98,7 +101,11 @@ export function MoveSheet({
{visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name);
const isCurrent = node.id === currentMailboxId;
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
// A shared account's header is a grouping row, not a folder, and
// Drafts is never a move target (the webmail excludes it too).
const canTarget =
!node.isAccountNode && node.myRights?.mayAddItems !== false && !isCurrent
&& node.role !== 'drafts';
return (
<Pressable
key={node.id}
@@ -115,7 +122,7 @@ export function MoveSheet({
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
numberOfLines={1}
>
{node.name}
{node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)}
</Text>
{isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable>
+19 -5
View File
@@ -2,6 +2,8 @@ import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from 'lucide-react-native';
import { useNetworkStore } from '../stores/network-store';
import { useOutboxStore } from '../stores/outbox-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -14,12 +16,24 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const online = useNetworkStore((s) => s.online);
const t = useLocaleStore((s) => s.t);
// Pending offline mutations that will replay once we're back online.
const queued = useOutboxStore((s) => s.entries.length);
if (online) return null;
const queuedHint = queued > 0
? t(
'offline.queued_changes',
'{count, plural, one {# change} other {# changes}} will sync when you reconnect',
{ count: queued },
)
: null;
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
const label = t('offline.banner', 'You are offline');
return (
<View style={styles.bar}>
<CloudOff size={14} color={c.primaryForeground} />
<View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite">
<CloudOff size={14} color={c.warningForeground} />
<Text style={styles.text} numberOfLines={1}>
You are offline{hint ? ` — ${hint}` : ''}
{suffix ? `${label} — ${suffix}` : label}
</Text>
</View>
);
@@ -33,11 +47,11 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: '#a16207',
backgroundColor: c.warning,
},
text: {
...typography.caption,
color: c.primaryForeground,
color: c.warningForeground,
flex: 1,
},
});
+158
View File
@@ -0,0 +1,158 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { BellRing, X } from 'lucide-react-native';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useAuthStore } from '../stores/auth-store';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import {
DEFAULT_RELAY_BASE_URL,
dismissPushPrompt,
getEffectiveRelayBaseUrl,
isPushEnabledForAccount,
isPushSupported,
PushSetupError,
setStoredRelayBaseUrl,
setupPushNotifications,
wasPushPromptDismissed,
} from '../lib/push-notifications';
// Mirrors the webmail's push-notification-prompt: a short delay after login
// so the inbox renders first, and never again for an account once dismissed.
const PROMPT_DELAY_MS = 1500;
/**
* Once-per-account invitation to turn on background notifications. Shown
* only where push can actually work (Android with Play services), when the
* user has not disabled mail notifications, and until either "Enable" or
* "Not now" has been tapped for the active account.
*/
export function PushOnboardingPrompt(): React.ReactElement | null {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const client = useAuthStore((s) => s.client);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const username = useAuthStore((s) => s.username);
const emailNotificationsEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
const [visible, setVisible] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
setVisible(false);
setError(null);
if (!isAuthenticated || !client || !activeAccountId || !emailNotificationsEnabled) return;
if (!isPushSupported()) return;
let cancelled = false;
const timer = setTimeout(() => {
void (async () => {
if (await wasPushPromptDismissed(activeAccountId)) return;
if (await isPushEnabledForAccount(activeAccountId)) return;
if (!cancelled) setVisible(true);
})();
}, PROMPT_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [isAuthenticated, client, activeAccountId, emailNotificationsEnabled]);
if (!visible || !activeAccountId) return null;
const handleDismiss = () => {
setVisible(false);
void dismissPushPrompt(activeAccountId);
};
const handleEnable = async () => {
setBusy(true);
setError(null);
try {
const relayBaseUrl = (await getEffectiveRelayBaseUrl()) || DEFAULT_RELAY_BASE_URL;
await setStoredRelayBaseUrl(relayBaseUrl);
await setupPushNotifications({ relayBaseUrl, accountLabel: username ?? undefined });
await dismissPushPrompt(activeAccountId);
setVisible(false);
} catch (err) {
const message = err instanceof PushSetupError || err instanceof Error
? err.message
: t('settings.notifications.push.setup_failed', 'Setup failed');
setError(message);
} finally {
setBusy(false);
}
};
return (
<View style={styles.card} accessibilityRole="summary">
<View style={styles.iconWrap}>
<BellRing size={18} color={c.primary} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.title}>
{t('push_prompt.title', 'Get notified about new mail')}
</Text>
<Text style={styles.body}>
{t(
'push_prompt.body',
'Turn on background notifications so new messages reach you while the app is closed.',
)}
</Text>
{error && <Text style={styles.error}>{error}</Text>}
<View style={styles.actions}>
<Button size="sm" onPress={() => void handleEnable()} loading={busy}>
{t('push_prompt.enable', 'Enable')}
</Button>
<Button size="sm" variant="ghost" onPress={handleDismiss} disabled={busy}>
{t('push_prompt.dismiss', 'Not now')}
</Button>
</View>
</View>
<Pressable
onPress={handleDismiss}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('settings.notifications.push.dismiss_aria', 'Dismiss notification prompt')}
style={styles.close}
>
<X size={16} color={c.mutedForeground} />
</Pressable>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
marginHorizontal: spacing.md,
marginTop: spacing.sm,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.card,
},
iconWrap: {
width: 36,
height: 36,
borderRadius: radius.full,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
title: { ...typography.bodyMedium, color: c.text },
body: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
error: { ...typography.caption, color: c.error, marginTop: spacing.xs },
actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
close: { padding: 2 },
});
}
+152
View File
@@ -0,0 +1,152 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
interface QrScanModalProps {
visible: boolean;
onClose: () => void;
// Fires once with the raw decoded QR string. The parent is responsible for
// closing the modal and interpreting the payload.
onScanned: (data: string) => void;
}
export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
const c = useColors();
const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c, theme), [c, theme]);
const t = useLocaleStore((st) => st.t);
const [permission, requestPermission] = useCameraPermissions();
// Guards against the camera firing onBarcodeScanned dozens of times for the
// same code before the modal tears down.
const handled = React.useRef(false);
// Once iOS/Android stops letting us re-prompt, the only path left is Settings.
const blocked = !!permission && !permission.granted && !permission.canAskAgain;
React.useEffect(() => {
if (visible) handled.current = false;
}, [visible]);
const handleBarcode = React.useCallback(
(result: BarcodeScanningResult) => {
if (handled.current) return;
handled.current = true;
onScanned(result.data);
},
[onScanned],
);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
style={StyleSheet.absoluteFill}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarcode}
/>
) : null}
{permission?.granted ? (
// Dims everything except the scan square. Four scrim regions leave a
// transparent hole so the camera shows through only inside the frame.
<View style={StyleSheet.absoluteFill} pointerEvents="none">
<View style={styles.scrim} />
<View style={styles.scrimCenterRow}>
<View style={styles.scrimSide} />
<View style={styles.frame} />
<View style={styles.scrimSide} />
</View>
<View style={[styles.scrim, styles.scrimBottom]}>
<View style={styles.hintWrap}>
<Text style={styles.hintTitle}>{t('login.mobile.qr_hint_title', 'Open Bulwark on your computer')}</Text>
<Text style={styles.hint}>
{t('login.mobile.qr_hint', 'Settings → Security → Link device shows a code. Point the camera at it.')}
</Text>
</View>
</View>
</View>
) : null}
<SafeAreaView style={styles.overlay}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
<X size={24} color={c.text} />
</Pressable>
<Text style={styles.title}>{t('login.mobile.qr_title', 'Sign-in code')}</Text>
<View style={styles.closeButton} />
</View>
{!permission?.granted ? (
<View style={styles.permissionWrap}>
<Text style={styles.permissionText}>
{blocked
? t('login.mobile.camera_disabled', 'Camera access is turned off. Turn it on in Settings to scan a sign-in QR code.')
: t('login.mobile.camera_needed', 'Scanning a sign-in QR code uses the camera. The QR code is read on this device only.')}
</Text>
<Button
variant="default"
size="md"
onPress={() => void (blocked ? Linking.openSettings() : requestPermission())}
>
{blocked ? t('login.mobile.open_settings', 'Open Settings') : t('login.mobile.continue', 'Continue')}
</Button>
</View>
) : null}
</SafeAreaView>
</View>
</Modal>
);
}
function makeStyles(c: ThemePalette, theme: 'light' | 'dark') {
const scrimColor = theme === 'light' ? 'rgba(248,250,252,0.72)' : 'rgba(0,0,0,0.6)';
return StyleSheet.create({
container: { flex: 1, backgroundColor: '#000000' },
overlay: { flex: 1, justifyContent: 'flex-start' },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm,
paddingBottom: spacing.md,
},
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
title: { ...typography.h3, color: c.text },
scrim: { flex: 1, backgroundColor: scrimColor },
scrimCenterRow: { flexDirection: 'row', height: 240 },
scrimSide: { flex: 1, backgroundColor: scrimColor },
scrimBottom: { alignItems: 'center', paddingTop: spacing.xl },
frame: {
width: 240,
height: 240,
borderWidth: 3,
borderColor: c.text,
borderRadius: radius.lg,
backgroundColor: 'transparent',
},
hintWrap: { alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.xxl },
hintTitle: { ...typography.bodySemibold, color: c.text, textAlign: 'center' },
hint: {
...typography.caption,
color: c.textSecondary,
textAlign: 'center',
},
permissionWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.lg,
paddingHorizontal: spacing.xxl,
backgroundColor: c.background,
},
permissionText: { ...typography.body, color: c.text, textAlign: 'center' },
});
}
+4 -1
View File
@@ -26,7 +26,7 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.container, style]}>
<View style={[styles.container, style]} accessibilityRole="radiogroup">
{options.map((opt) => {
const selected = opt.value === value;
return (
@@ -34,6 +34,9 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
key={opt.value}
style={[styles.option, selected ? styles.optionSelected : styles.optionUnselected]}
onPress={() => onChange(opt.value)}
accessibilityRole="radio"
accessibilityState={{ selected, checked: selected }}
accessibilityLabel={opt.label}
>
<Text style={[styles.optionText, selected ? styles.textSelected : styles.textUnselected]}>
{opt.label}
+78 -275
View File
@@ -3,10 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useColors } from '../theme/colors';
import type { ThemePalette } from '../theme/tokens';
// Minimum visible editor height (px). The editor auto-grows beyond this as the
// user types, and the parent ScrollView handles overflow.
const MIN_HEIGHT = 220;
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
export type RichTextCommand =
| 'bold'
@@ -24,7 +21,9 @@ export type RichTextCommand =
| 'justifyRight'
| 'removeFormat'
| 'undo'
| 'redo';
| 'redo'
// Text colour: a CSS colour after the colon, e.g. 'foreColor:#d97706'.
| `foreColor:${string}`;
export interface RichTextSelectionState {
bold: boolean;
@@ -53,7 +52,16 @@ export interface RichTextEditorHandle {
insertLink(url: string, label?: string): void;
unsetLink(): void;
insertImage(src: string, cid?: string, alt?: string): void;
/** Insert an HTML fragment at the caret (template bodies, tables). */
insertHtml(html: string): void;
setHtml(html: string): void;
/**
* Read the live editor DOM content. Resolves with the current innerHTML or
* rejects after `timeoutMs` when the page script is dead or the bridge is
* broken — callers must treat that as "content unknown", never fall back to
* possibly-stale state (issue #9: doing so silently sent blank replies).
*/
getHtml(timeoutMs?: number): Promise<string>;
focus(): void;
}
@@ -66,273 +74,6 @@ interface Props {
onBlur?: () => void;
}
function buildEditorHtml(opts: {
initialHtml: string;
placeholder: string;
c: ThemePalette;
}): string {
const { initialHtml, placeholder, c } = opts;
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body).
const initialJson = JSON.stringify(initialHtml);
const placeholderJson = JSON.stringify(placeholder);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
overflow-y: hidden;
-webkit-text-size-adjust: 100%;
}
#editor {
outline: none;
padding: 12px 16px 24px;
min-height: ${MIN_HEIGHT}px;
word-wrap: break-word;
overflow-wrap: anywhere;
}
#editor[data-empty="true"]::before {
content: attr(data-placeholder);
color: ${c.textMuted};
pointer-events: none;
position: absolute;
}
#editor p { margin: 0 0 12px 0; }
#editor p:last-child { margin-bottom: 0; }
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
#editor li { margin-bottom: 4px; }
#editor blockquote {
margin: 0 0 12px 0;
padding: 4px 0 4px 12px;
border-left: 3px solid ${c.border};
color: ${c.textSecondary};
}
#editor a { color: ${c.primary}; text-decoration: underline; }
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
#editor pre, #editor code {
background: ${c.surfaceActive};
border-radius: 4px;
padding: 2px 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
}
#editor pre { padding: 8px 12px; overflow-x: auto; }
::selection { background: ${c.primary}33; }
</style>
</head>
<body>
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
<script>
(function () {
var editor = document.getElementById('editor');
var lastHtml = null;
var lastHeight = 0;
function refreshEmpty() {
var html = editor.innerHTML;
var isEmpty =
html === '' ||
html === '<br>' ||
html === '<p></p>' ||
html === '<p><br></p>' ||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
}
function post(type, payload) {
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
}
}
function reportHeight() {
var h = Math.max(${MIN_HEIGHT}, editor.scrollHeight + 8);
if (Math.abs(h - lastHeight) < 2) return;
lastHeight = h;
post('height', h);
}
function reportChange() {
refreshEmpty();
var html = editor.innerHTML;
if (html === lastHtml) return;
lastHtml = html;
post('change', html);
}
function reportSelection() {
function active(name) {
try { return document.queryCommandState(name); } catch (e) { return false; }
}
function inBlock(tag) {
var sel = window.getSelection();
if (!sel || !sel.rangeCount) return false;
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === tag) return true;
node = node.parentNode;
}
return false;
}
var state = {
bold: active('bold'),
italic: active('italic'),
underline: active('underline'),
strikeThrough: active('strikeThrough'),
ul: active('insertUnorderedList'),
ol: active('insertOrderedList'),
blockquote: inBlock('BLOCKQUOTE'),
h1: inBlock('H1'),
h2: inBlock('H2'),
alignLeft: active('justifyLeft'),
alignCenter: active('justifyCenter'),
alignRight: active('justifyRight'),
link: inBlock('A'),
};
post('selection', state);
}
// ── Initial content ────────────────────────────────────────────────
var initial = ${initialJson};
if (initial && typeof initial === 'string') editor.innerHTML = initial;
refreshEmpty();
lastHtml = editor.innerHTML;
editor.addEventListener('input', function () {
reportChange();
reportHeight();
});
editor.addEventListener('blur', function () { post('blur', null); });
editor.addEventListener('focus', function () { post('focus', null); });
document.addEventListener('selectionchange', function () {
if (document.activeElement === editor) reportSelection();
});
// ── Bridge: receive commands from RN ───────────────────────────────
window.__rne = {
exec: function (command, value) {
editor.focus();
try {
if (command === 'formatBlock' && value) {
// Toggle: if we're already in that block, switch to <p>.
var sel = window.getSelection();
if (sel && sel.rangeCount) {
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
document.execCommand('formatBlock', false, '<p>');
reportChange();
reportHeight();
reportSelection();
return;
}
node = node.parentNode;
}
}
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
} else {
document.execCommand(command, false, value);
}
} catch (e) {}
reportChange();
reportHeight();
reportSelection();
},
setHtml: function (html) {
editor.innerHTML = html || '';
lastHtml = editor.innerHTML;
refreshEmpty();
reportChange();
reportHeight();
},
insertHtml: function (html) {
editor.focus();
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
insertLink: function (url, label) {
editor.focus();
// Reject anything that isn't a safe scheme or an absolute path, so the
// user can't accidentally (or via a malicious paste) ship a
// javascript:/data: link to their recipient.
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
var trimmed = (url || '').trim();
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
var sel = window.getSelection();
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
if (hasSelection) {
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
// execCommand doesn't set rel; tag every anchor that points at this URL.
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
if (a.getAttribute('href') === trimmed) {
a.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
} else {
var text = label && label.trim() ? label : trimmed;
var safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
}
reportChange();
reportHeight();
reportSelection();
},
unsetLink: function () {
editor.focus();
try { document.execCommand('unlink', false, null); } catch (e) {}
reportChange();
reportSelection();
},
insertImage: function (src, cid, alt) {
editor.focus();
var safeSrc = (src || '').replace(/"/g, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
focus: function () { editor.focus(); },
};
reportHeight();
reportChange();
// Re-measure after async layout (image loads, font swap).
window.addEventListener('load', function () {
setTimeout(reportHeight, 50);
setTimeout(reportHeight, 200);
setTimeout(reportHeight, 600);
});
if (window.ResizeObserver) {
new ResizeObserver(reportHeight).observe(editor);
}
})();
true;
</script>
</body>
</html>`;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
ref,
@@ -340,7 +81,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const webViewRef = React.useRef<WebView>(null);
const [height, setHeight] = React.useState(MIN_HEIGHT);
const [height, setHeight] = React.useState(MIN_EDITOR_HEIGHT);
const onChangeRef = React.useRef(onChange);
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
@@ -360,13 +101,46 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
[],
);
// Scripts injected before the page has run its inline script are lost, and
// the composer talks to the editor as soon as async data arrives (signature
// after the identities load, hydrated inline images). Queue until the page
// posts its first message, then replay in order.
const readyRef = React.useRef(false);
const queuedRef = React.useRef<string[]>([]);
const call = React.useCallback((script: string) => {
if (!readyRef.current) {
queuedRef.current.push(script);
return;
}
webViewRef.current?.injectJavaScript(`${script}; true;`);
}, []);
// Pending getHtml() readbacks, keyed by request id. Timers reject requests
// the page never answers (dead script / broken bridge).
const snapshotSeq = React.useRef(0);
const pendingSnapshots = React.useRef(
new Map<number, {
resolve: (html: string) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>(),
);
React.useEffect(() => {
const pending = pendingSnapshots.current;
return () => {
for (const { timer, reject } of pending.values()) {
clearTimeout(timer);
reject(new Error('Editor unmounted'));
}
pending.clear();
};
}, []);
React.useImperativeHandle(ref, () => ({
exec: (command) => {
const [name, value] = command.split(':');
const sep = command.indexOf(':');
const name = sep === -1 ? command : command.slice(0, sep);
const value = sep === -1 ? undefined : command.slice(sep + 1);
if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`);
},
@@ -379,9 +153,22 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
insertImage: (src, cid, alt) => {
call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`);
},
insertHtml: (html) => {
call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`);
},
setHtml: (next) => {
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
},
getHtml: (timeoutMs = 2000) =>
new Promise<string>((resolve, reject) => {
const id = ++snapshotSeq.current;
const timer = setTimeout(() => {
pendingSnapshots.current.delete(id);
reject(new Error('Editor did not answer'));
}, timeoutMs);
pendingSnapshots.current.set(id, { resolve, reject, timer });
call(`window.__rne && window.__rne.getHtml(${id})`);
}),
focus: () => {
call(`window.__rne && window.__rne.focus()`);
},
@@ -394,13 +181,29 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
} catch {
return;
}
if (!readyRef.current) {
readyRef.current = true;
const queued = queuedRef.current;
queuedRef.current = [];
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
}
switch (data.type) {
case 'change':
onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : '');
break;
case 'height': {
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_HEIGHT, h));
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_EDITOR_HEIGHT, h));
break;
}
case 'htmlSnapshot': {
const p = data.payload as { id?: number; html?: string } | null;
const entry = typeof p?.id === 'number' ? pendingSnapshots.current.get(p.id) : undefined;
if (entry) {
pendingSnapshots.current.delete(p!.id!);
clearTimeout(entry.timer);
entry.resolve(typeof p?.html === 'string' ? p.html : '');
}
break;
}
case 'selection':
+8 -2
View File
@@ -15,13 +15,19 @@ interface SenderAvatarProps {
email?: string | null;
size?: number;
style?: StyleProp<ViewStyle>;
/**
* Never load a remote image (favicon) — initials only. The list passes this
* inside Junk unless `showAvatarsInJunk` is on (webmail 1.5.1), so a spam
* sender's domain is not contacted by merely listing the folder.
*/
disableImages?: boolean;
}
// Mirrors the webmail `Avatar` priority for the subset available on native:
// company favicon (if sender domain is non-personal and `senderFavicons` is on)
// → initials over HSL-hashed color.
export default function SenderAvatar({ name, email, size = 40, style }: SenderAvatarProps) {
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
export default function SenderAvatar({ name, email, size = 40, style, disableImages }: SenderAvatarProps) {
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages;
const [imgError, setImgError] = React.useState(false);
React.useEffect(() => {

Some files were not shown because too many files have changed in this diff Show More