Tick the parity items the fix pass finished (server-side recurrence, filters and vacation for shared accounts, in-app file preview, the trusted-senders toggle, UnifiedPush, sender taps in the viewer), and note on the already ticked ones what this pass changed, such as the tag view and search reaching shared accounts or contact photos as avatars. Items that only moved forward say what is left. Fix hints and "verified" lines that named removed functions (`setEmailKeywords`, `getEmailDetail`, `fetchEmailsChunked`, `buildRecurrenceOverridePatch`) or the old whole-map keyword writes now point at what replaced them. PARITY_CHECKLIST.md counts done and open items per area: 379 of 415 are done, 36 open.
54 KiB
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)
-
No request deadline: a stalled socket hangs send/save forever (#702) —
P1—bugfix-parity— fixed in0b1c240- What WEB does:
timedFetchputs a 30 s deadline on response headers (300 s for blob transfers), throwsRequestTimeoutError, 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()callsecureFetchwith noAbortController/timeout (src/api/jmap-client.ts:405-447,363-386,568-584). RNfetchhas 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.tswith anAbortController+setTimeout(30 s API, 300 s blob), throw aRequestTimeoutErrorsubclass, and makeisTransientNetworkErrorclassify it as transient only for idempotent ops (never re-sendEmailSubmission/set).
- What WEB does:
-
maxConcurrentRequestsrefusal (400jmap:error:limit) is a hard failure (#780) —P2—bugfix-parity— fixed in0b1c240- 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 tohandleStateChangein 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
isConcurrentRequestRefusaland the retry loop intoJMAPClient.request(); read the body text before deciding.
- What WEB does:
-
No transient-network retry —
P2—missing— fixed in0b1c240- What WEB does: retries once after 1 s on a fetch
TypeErrorunless already reconnecting or timed out (lib/jmap/client.ts:836-846). - What RN does:
request()propagates the rawTypeError("Network request failed")(src/api/jmap-client.ts:427);isTransientNetworkErrorrecognises 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.
- What WEB does: retries once after 1 s on a fetch
-
429 handling: no rate-limit state, no pause of live updates, HTTP-date
Retry-Afterbecomes NaN —P2—partial— fixed in0b1c240(rate-limit window +onRateLimit; SSE/polling pause via the window check inrequest())- What WEB does:
setRateLimitedblocks all requests until the window ends, tears down and re-arms push, notifies UI viaonRateLimitand a throttled toast event;parseRetryAfterhandles 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) whereparseIntof an HTTP-date yieldsNaN*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
rateLimitedUntilto the client, short-circuitrequest()while limited, close SSE/polling and re-arm after the window, expose toOfflineBanner.
- What WEB does:
-
HTTP error body and cause not surfaced; JSON parse unguarded —
P3—partial— fixed in0b1c240- 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}andresponse.json()unguarded (src/api/jmap-client.ts:442-446);loadAccountwraps intoNetworkErrorbutrequest()throws bareTypeError. - Fix hint: read text, include in message, wrap
JSON.parse; throwNetworkErrorfromrequest()too.
- What WEB does: error message includes status + first 200 chars of body, JSON parse failure mapped to a clear error (
-
Method-level
error/notUpdated/notDestroyedunchecked in most RN API functions —P1—rn-only-bug— fixed inccbe67c(mail/identity/push; contacts, calendar, files done by their agents)- What WEB does: mutations inspect
notUpdated/notDestroyed(lib/jmap/client.ts:2282-2286moveEmail;3290-3345sendEmail), reads check the method name before indexing (1207-1208). - What RN does:
res.methodResponses[0][1].list/.idsindexed blindly ingetMailboxesWithState(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/setmutations 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 aTypeError(classified transient bynetwork-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)(likeresultForinsrc/api/account-security.ts:59-67) and anassertSetResult(body, ids)that throws onnotUpdated/notDestroyed; use them in everysrc/api/*.tsfunction.
- What WEB does: mutations inspect
-
Email/getandEmail/setnot split tomaxObjectsInGet/maxObjectsInSet; nogetMaxObjectsInSet—P2—bugfix-parity— fixed inccbe67c- What WEB does:
batched(ids, getMaxObjectsInGet())for gets (lib/jmap/client.ts:713-727),getMaxObjectsInSet()forbatchMarkAsRead,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 unboundedaddedIds + updatedIdslist (src/stores/email-store.ts:848-857),getMailboxesByIdsafterMailbox/changesis unbounded (email.ts:114-127, store565-567),listScheduledEmailsfetches up to 200 ids in one get (email.ts:922-928);setKeywordsForEmails,moveEmails,restoreEmailMailboxes,destroyEmails,archiveEmailsput every selected id into oneEmail/set(nomaxObjectsInSetaccessor exists injmap-client.ts). OnlyfetchEmailsChunked(email-store.ts:371-381),getEvents/getContacts(src/api/calendar.ts:214,contacts.ts:45) andoffline-sync.ts:92chunk. 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()+ abatched()helper tojmap-client.ts; apply in the listed functions.
- What WEB does:
-
maxSizeUpload/maxSizeRequestignored —P3—missing— fixed in0b1c240(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
maxSizeUploadanywhere (grep -rn maxSizeUpload srcempty);uploadBlob(src/api/blob.ts:5-62) surfaces an opaqueUpload failed: 413. - Fix hint: expose
getMaxSizeUpload()and check beforeFile(uri).bytes()inblob.ts/ComposeScreen.
- What WEB does:
-
Relative session URLs are not resolved (37f830da) —
P2—bugfix-parity— fixed in0b1c240(unified-inbox.ts with the mail-list agent)- What WEB does:
rewriteSessionUrlrebases both absolute and relative (/jmap/)apiUrl/downloadUrl/uploadUrl/eventSourceUrlonto 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:
rewriteSessionUrlsonly rewrites whenextractOrigin(url)matches^https?://(src/api/jmap-client.ts:320-340); a relativeapiUrlis returned unchanged andfetch('/jmap/')then fails with "Network request failed". Same gap insrc/api/unified-inbox.ts:55-60. (RN correctly avoidsnew URL()so templates are not corrupted.) - Fix hint: in
rewrite(), whenextractOriginis null and the string starts with/, returnserverOrigin + url.
- What WEB does:
-
Capability check uses the broad
principalsURN, notprincipals:owner(44360024) —P2—bugfix-parity— fixed in a0ce925; since Stalwart advertisesprincipals:ownernowhere, that also hid sharing, so sincecaea3d0the sharing UI is gated onprincipalsandprincipals:owneronly goes intousingwhen advertised- What WEB does: declares
urn:ietf:params:jmap:principals:ownerinusingonly 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()checksCAPABILITIES.PRINCIPALS(src/api/files.ts:23-25) and then pushesPRINCIPALS_OWNERintousing(files.ts:40-43). A server advertisingprincipalsbut notprincipals:ownerrejects every FileNode request withunknownCapability. - Fix hint: check
hasCapability(CAPABILITIES.PRINCIPALS_OWNER) || accountCapabilities[...]before adding it.
- What WEB does: declares
-
PatchObject pointer keys not JSON-Pointer escaped (5c484af1) —
P3—bugfix-parity— fixed inccbe67c- 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/moveEmailsbuildmailboxIds/${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. (Since5041897keywords are sent askeywords/<name>pointers too, escaped bykeywordPointerinsrc/api/patch-pointer.ts.) - Fix hint: add
pointerToken()or switchmoveEmail(s)to the fullmailboxIdsreplacement RN already uses inrestoreEmailMailboxes.
- What WEB does:
-
Send: message created directly in Sent instead of Drafts +
onSuccessUpdateEmail(#188) —P2—bugfix-parity— fixed inccbe67c- What WEB does: creates in Drafts with
$draft, submits, and moves to Sent viaonSuccessUpdateEmail(fullmailboxIdsreplacement,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 }, noonSuccessUpdateEmail(src/api/email.ts:786-793,836-846). With Stalwart encryption-at-rest +encryptOnAppendthe copy is encrypted beforeEmailSubmission/setreads 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 reportnotUpdatedas a filing warning.
- What WEB does: creates in Drafts with
-
Message-ID / In-Reply-To handling diverges from WEB —
P3—bugfix-parity— fixed inccbe67c- What WEB does: generates
messageIdclient-side from the sender domain, strips angle brackets frominReplyTo/referencesand 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:asTextstrings (src/api/email.ts:823-826). - Fix hint: port
generateMessageId+stripMessageIdBrackets, useinReplyTo/referencesarrays.
- What WEB does: generates
-
Dead duplicate
src/api/submission.tswith zero error checking —P3—rn-only-bug— fixed in42ab246- What RN does:
submission.ts:140-199is an oldersendEmailthat ignoresnotCreated/errors; no importers (grep api/submissionempty). Risk is someone wiring it back in. - Fix hint: delete the file.
- What RN does:
-
No keep-alive ping / connection-lost signalling / session refresh —
P2—missing— fixed inedc26ce- What WEB does:
Core/echoevery 30 s with exponential skip back-off,reconnect()on failure,onConnectionChangedrives the "connection lost" banner and per-accountisConnected(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 srcempty); 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 throwsAuthenticationErrorstraight away (src/api/jmap-client.ts:432-434). - Fix hint: add a 30 s
Core/echowhile foregrounded (see AppState finding) feedingnetwork-store.online/account-store.isConnected; on 401 for basic auth re-fetch the session once before failing.
- What WEB does:
-
Password change leaves the stored credential stale, then evicts the account —
P1—rn-only-bug— fixed in032732c- 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:
changePasswordsucceeds (src/api/account-security.ts:185-197,src/components/settings/AccountSecuritySettings.tsx:137-141) butJMAPClient.credentials.passwordand the SecureStore entry keep the old password. The next request gets 401 ->AuthenticationError; on the next launchrestoreSessionclears credentials and removes the account (src/stores/auth-store.ts:516-523), andretrySessiondoes the same (571-587). Push background task also starts failing. - Fix hint: add
jmapClient.updatePassword(newPassword)that rewritescredentialsand persists viasetStoredCredentials; call it after a successfulchangePassword(and after TOTP enable/disable if the password format changes).
- What WEB does: (same class of exposure via cookie, but the server-side passthrough re-reads; out of scope).
-
First-touch serialisation for Calendar/Contacts (#907) —
P3—bugfix-parity— fixed in0b1c240- What WEB does:
FirstTouchGateholds 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:
refetchFeatureStoresfiresfetchContacts,fetchCalendars,refreshconcurrently on every login/restore/switch (src/stores/auth-store.ts:81-99). - Fix hint: port
first-touch-gate.tsand wraprequest(); reset it inreset()/loadAccount.
- What WEB does:
-
Mailbox fetch not retried on first login (lazy provisioning, #217) —
P3—bugfix-parity— fixed in3194b5e- 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:
fetchMailboxesdoes oneMailbox/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
inboxrole, retry after ~1 s up to 3 times.
-
Session redirect with dropped Authorization not handled —
P3—partial— fixed in 0b1c240; the check keyed onresponse.redirected, which React Native's fetch never sets, so it only works since859770b(audit B15)- 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:
fetchSessionaccepts whatever comes back;resolveAccountIdthen 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.
- What WEB does: detects
Live updates (SSE / polling / state changes)
-
No
AppStatehandling: SSE never paused in background nor recovered on foreground; no refresh on resume —P1—missing— fixed inedc26ce- What WEB does:
visibilitychange-> immediatecheckForStateChanges()+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.tsxis empty. The EventSource is opened once (App.tsx:380-418,src/api/push.ts:120-157).react-native-ssere-polls only when the XHR reaches DONE with a status (node_modules/react-native-sse/src/EventSource.js:118-131); ononerror/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'): onbackgroundclose the EventSource; onactiverecreate it and callfetchMailboxes()/refreshEmails()/outboxflush(); also subscribe touseNetworkStoreonlinetransitions to recreate. Listen for the SSEerrorevent and schedule a reconnect with back-off.
- What WEB does:
-
SSE Authorization header captured once; stale after OAuth token refresh —
P1—rn-only-bug— fixed inedc26ce- What WEB does:
authenticatedFetchreadsthis.authHeaderon every (re)connect, andonTokenRefreshretries a 401 SSE connect (lib/jmap/client.ts:7049-7053,855-863). - What RN does:
connectEventSourcepasses{ headers: { Authorization: jmapClient.authHeader } }at construction (src/api/push.ts:136-138);react-native-ssereusesthis.headerson every reconnect (EventSource.js:83-87). After the access token expires (ensureFreshTokenrotates 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
errorwithxhrStatus === 401close and recreate with the currentauthHeader; better, recreate the EventSource wheneverpersistRefreshedTokensruns.
- What WEB does:
-
Push effect keyed on the singleton
client: SSE stays bound to the previous account afterswitchAccount—P1—rn-only-bug— fixed inedc26ce- What WEB does: per-account clients; the push effect depends on
activeAccountIdand 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);clientis always the samejmapClientobject (src/stores/auth-store.ts:155), andisAuthenticatedstays true acrossswitchAccount(auth-store.ts:360-432), so the effect never re-runs. The open EventSource keeps the old account'seventSourceUrl+ credentials; its events are then dropped byjmapClientServesActiveAccount(src/stores/email-store.ts:986), and the new account gets no live updates until logout/login. The FCM setup effect already includesactiveAccountId(App.tsx:378). - Fix hint: add
activeAccountId(andhaveLiveSession) to the push effect deps.
- What WEB does: per-account clients; the push effect depends on
-
No SSE failure/fallback path (server 4xx/5xx, proxy without SSE, client cert) —
P2—partial— fixed inedc26ce- What WEB does: non-OK SSE response ->
fallbackToPolling; stream end -> reconnect after 3 s (rate-limit aware);closeafter/pingtemplate substitution (lib/jmap/client.ts:7049-7066,7129-7155). - What RN does:
startPollingis used only wheneventSourceUrlis absent (src/api/push.ts:228-230); noerror/openlisteners on the EventSource. A user with a TLS client certificate (native issue #3) is worst hit:react-native-sseis XHR-based and cannot present the KeyChain cert (src/lib/client-cert.ts:40-43explains whyfetchSecureexists), so the SSE handshake is rejected and no polling starts -> no live updates at all. - Fix hint: in
connectEventSource, ifawait getClientCertAlias()is non-null skip SSE and returnstartPolling(...); add anerrorhandler that, after N consecutive non-401 failures, closes the source and falls back to polling.
- What WEB does: non-OK SSE response ->
-
StateChange coverage: EmailSubmission, SieveScript, Identity, VacationResponse, FileNode ignored —
P2—partial— fixed ina071d05(Sieve/vacation refetch; EmailSubmission/FileNode viastate-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/CalendarEventincl. 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.tsempty). - 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 theonStateChangefan-out inApp.tsx:391-396.
- What WEB does: handles
-
Contacts state changes ignore shared/group accounts —
P3—partial— done in26a34d5(handleStateChangewalksgetContactCapableAccountIds())- What WEB does: per-account clients;
handleStateChangereacts 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.handleStateChangeonly readschange.changed[jmapClient.accountId](src/stores/contacts-store.ts:154-156) although address books from shared accounts are shown;calendar-storealready folds shared accounts in (calendar-store.ts:263-272). - Fix hint: mirror the calendar store's
knownset usingaddressBook.accountId.
- What WEB does: per-account clients;
-
Polling fallback is coarse: primary account only, fixed 5 s, no in-flight coalescing, no pause in background —
P3—partial— fixed inedc26ce- 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:
startPollingpollsMailbox/get+Email/getfor the primary account every 5 s withsetInterval, 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.
- What WEB does: polls every session account plus Calendar/CalendarEvent/SieveScript, 3 s active / 20 s slow, skips when
-
SSE stream keeps the whole response in memory; no periodic recycle —
P3—rn-only-bug— fixed inedc26ce- 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-sseaccumulatesxhr.responseTextfor 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 nopingevent arrived in 90 s (add apinglistener).
- What WEB does: reads the body as a stream and discards processed chunks; recycles a silent stream after 90 s (
-
Push subscription
typesincludeEmailandMailbox; noemailPushspam filter —P2—partial— fixed in 00faceb; theemailPushmap was refused for ACL-shared mailboxes and never read back untild8046a8andccaa24e(audit B18)- What WEB does: subscribes to
EmailDeliveryonly, plus anemailPushfilter excluding$junk/Junk when the server advertisesurn: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);createPushSubscriptionhas noemailPushsupport (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 inboxnotKeyword $seenquery hides it, at the cost of a wake-up). - Fix hint: subscribe to
EmailDeliveryonly; addhasEmailPushCapability()+emailPushfilter as in WEB; the relay must forward it (see memory note: relay lacks EmailPush).
- What WEB does: subscribes to
-
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:
processAccountForPushcallsjmapClient.loadAccount(accountId)for each candidate account (src/lib/push-background-task.ts:445) and thefinallyre-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 finalloadAccountleavessession = 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, ownapiUrl) instead of mutating the singleton.
Offline cache & outbox
-
Outbox retries forever on errors that look transient but are permanent —
P2—rn-only-bug— fixed in0205aa0- What RN does:
isTransientNetworkErrortreats anyTypeErrorand messages containingsession expired/not connectedas transient (src/lib/network-error.ts:48-63). (a) A JMAP method error makes the unchecked read path throwTypeError: Cannot read property 'list' of undefined(see method-error finding) -> transient ->recordError(..., false)never incrementsattempts(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 byTypeError/message; treatAuthenticationErroras terminal and pause the queue.
- What RN does:
-
Permanently failed outbox ops are dropped silently; optimistic state never reverted —
P2—rn-only-bug— fixed in0205aa0- 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;OfflineBanneronly shows the count. - Fix hint: keep a
failed: OutboxEntry[]in the store, surface it inOfflineBanner/AboutDataSettings, and triggerrefreshEmails()for the affected mailbox when an op is dropped.
- What RN does: after 5 attempts the entry is removed with
-
Interrupted flush is not resumed until the next online transition or launch —
P3—rn-only-bug— fixed in0205aa0- What RN does:
flush()breaks on the first transient error (src/stores/outbox-store.ts:203-206); the only triggers arehaveLiveSessionand NetInfoonlineflips (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.
- What RN does:
-
Offline
onlineRunfallback silently changes semantics (archive auto-foldering) —P3—rn-only-bug— fixed in0205aa0- What RN does:
applyOrQueueBatchruns the richeronlineRun(e.g. year/month archive foldering) and on transient failure re-queues the plainmailboxesprimitives (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
archiveop kind that re-runs the foldering logic on replay).
- What RN does:
-
Email/changes.hasMoreChangesignored in the incremental refresh —P3—partial— fixed inf07e039- What WEB does: WEB refetches the page instead of diffing, so it is not exposed.
- What RN does:
getEmailChangesreturnshasMoreChanges(src/api/email.ts:355-376) butrefreshEmailsusesupdated/destroyedonce (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.
-
Offline cache + outbox land in Android auto-backup —
P2—missing(security) — fixed in7f9f5af- What WEB does: N/A (no persistent mail cache).
- What RN does:
android:allowBackup="true"with@xml/secure_store_backup_rulesfrom 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 bodieswebmail:offline-cache:entry:v2:*, outbox, settings, account registry) is backed up to the user's Google account. - Fix hint: add an app-owned
fullBackupContent/dataExtractionRulesexcludingdatabases/RKStorage(AsyncStorage) or setallowBackup=falsevia an Expo config plugin.
-
Send has no offline path and no draft fallback —
P2—missing— partly: drafts autosave to the server since5b72c4d, and since92b42e4the close dialog no longer drops the text when the draft can't be saved; an offline send is still not queued, and a draft can't be saved offline- 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:
ComposeScreenshowsAlert('Send failed')and keeps the modal (src/screens/ComposeScreen.tsx:975-979); there is no draft API insrc/api/email.ts(grep -n '[Dd]raft' src/api/email.tsonly 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
outboxsendop kind (blob uploads must already be done) or at minimum persist the composer state locally on failure.
Security hardening (RN)
-
Linking.openURLwith unvalidated URIs from server data (contacts, calendar invitations) —P2—rn-only-bug— fixed in96272b6(contacts63bf392, 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);isValidUnsubscribeUrlgates 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) andLinking.openURL(videoUri)onvirtualLocations[0].urifrom an incoming invitation (src/components/calendar/EventDetailSheet.tsx:169,234;src/components/email/CalendarInvitationBanner.tsx:92,159) accept any scheme:intent://...#Intent;component=...;endlaunches arbitrary exported activities on Android,file://,content://, or third-party deep links. EmailBodyView already restricts tohttps?|mailto|tel|sms(src/components/EmailBodyView.tsx:715-723). - Fix hint: a shared
openExternalUrl()that allows onlyhttps?:,mailto:,tel:,sms:and shows the host in a confirmation for invitation links.
- What WEB does: links are sanitized in the viewer; iCal/vCard URLs open via anchor with
-
Password handoff over a custom-scheme redirect (
bulwarkmobile://…#password=) is interceptable; OAuthstateusesMath.random—P1—rn-only-bug— fixed in2c0dbd1(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
passwordflow returns the clear-text password in the redirect fragment (src/lib/oauth.ts:92-99); the scheme is a plainintent-filter(AndroidManifest.xml:25-30), which any installed app can also register (no App Links verification), so a malicious app can receive the redirect.stateis 16 bytes ofMath.random(oauth.ts:41-47) - it prevents a forged callback from being accepted only if unpredictable; and the fragment'sserver_url,token_endpoint,client_idare 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); generatestatewithexpo-cryptogetRandomBytes; validateserver_url/token_endpointare https and the token endpoint host matches the server host.
- What WEB does: the mobile handoff page redirects with credentials in the fragment (
-
http://server URLs accepted without a warning; cleartext then fails opaquely —P2—missing— fixed ince97232- 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:
normalizeServerInputacceptshttp(src/lib/server-discovery.ts:28-30), QR payloads accepthttp(src/lib/oauth.ts:144,155),connect()never checks the scheme. Android has nousesCleartextTraffic(app.config.jshas noandroid.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://innormalizeServerInputexceptlocalhost/RFC-1918 in dev builds, with a clear message.
-
Weak randomness for TOTP secret / UUIDs (falls back to
Math.random) —P2—rn-only-bug— fixed in2c0dbd1- What WEB does: server-side
otpauthSecret({size:20})from Node crypto;generateUUID()guarded (changelog "Replace unguarded crypto.randomUUID() with safe generateUUID()"). - What RN does:
randomBytestriescrypto.getRandomValues, thencrypto.randomUUID, thenMath.random(src/lib/totp.ts:404-437).expo-cryptois not a dependency (package.json), Hermes exposes nocryptoglobal by default, so a long-lived 2FA secret is minted fromMath.random; the same fallback is documented forlib/uuid.tsandlib/oauth.ts. - Fix hint: add
expo-cryptoand useCrypto.getRandomBytes(20)/Crypto.randomUUID(); remove the Math.random branches.
- What WEB does: server-side
-
Client-cert native fetch follows redirects with Authorization and turns POST into GET (#627 class) —
P3—bugfix-parity— fixed in6f747db- What WEB does:
postJmapfollows redirects manually, keeps POST, only to the same host or an https upgrade (lib/stalwart/jmap-api.ts:44-93). - What RN does:
instanceFollowRedirects = trueonHttpURLConnection(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-certfetchpath 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 insecureFetchwith the same host/https-upgrade rule.
- What WEB does:
-
Client-cert path buffers whole bodies through base64 over the bridge with a 30 s cap —
P3—rn-only-bug— fixed in6f747db(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 throughsecureFetch(...).arrayBuffer()(src/lib/email-export.ts:100-110) and uploads throughbodyToBase64(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.
- What RN does: the whole response is read into memory and base64-encoded (
-
Blob downloads via
File.downloadFileAsyncbypass token refresh —P2—rn-only-bug— fixed ind2ed27f- What WEB does:
fetchBlobgoes throughauthenticatedFetch, which refreshes a Bearer token on 401 (lib/jmap/client.ts:4150-4157,855-863). - What RN does:
downloadIntobuilds the header fromjmapClient.authHeaderwithoutensureFreshToken()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.fetchBlobArrayBufferdoes refresh (src/api/jmap-client.ts:575-581). - Fix hint: expose
ensureFreshToken()publicly, call it beforedownloadFileAsync, and on 401 callforceRefreshToken()and retry once.
- What WEB does:
-
Downloaded attachment temp files are never cleaned up —
P3—rn-only-bug— fixed ind2ed27f(launch sweep wired in App.tsx)- What RN does:
shareAttachment/openAttachmentwrite intoPaths.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.cacheolder than a day at launch.
- What RN does:
-
WebView:
data:navigations allowed inside the message frame —P3—partial— fixed in06742ef- What WEB does: message HTML is sandboxed in an
srcDociframe with a CSP meta tag (changelog 1.5.x);data:top-level navigation is blocked by the browser. - What RN does:
onShouldStartLoadWithRequestreturnstruefor anydata: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 forrequest.isTopFrame === falseor block it entirely (the body is loaded viasource={{html}}, not a data: URL).
- What WEB does: message HTML is sandboxed in an
-
No screenshot / recent-apps protection option —
P3—missing- What WEB does: N/A.
- What RN does: no
FLAG_SECURE/expo-screen-captureanywhere; mail content appears in the Android recents thumbnail. - Fix hint: optional "Hide content in app switcher" setting using
expo-screen-capturepreventScreenCaptureAsync.
-
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__.
- What RN does:
-
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.tsis 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-cryptois added (see randomness finding) replace withCrypto.digest('SHA-256', bytes); keep the size check.
- What RN does:
-
TOTP helper: RFC 6238 compatible; issuer hard-coded —
P3—partial— fixed ince97232- What WEB does: server-verified via
x:AccountPassword/set otpAuth.otpCode; enrolment viaotpauthwith the app/brand as issuer. - What RN does:
generateTotpEnrolmentproducesotpauth://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 (commenttotp.ts:391-396). IssuerStalwartinstead of the server host/brand. - Fix hint: use the server hostname (or configured brand) as issuer.
- What WEB does: server-verified via
-
Relay base URL stored unvalidated (http allowed) —
P3—rn-only-bug— fixed inf6f26f7- What RN does:
setStoredRelayBaseUrlonly 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.
- What RN does:
Stalwart admin/self-service, S/MIME, encryption at rest
-
Encryption at rest is read-only; no public-key management — fixed in
ddae1e6—P2—partial— fixed in69bedf0(API; settings UI with the settings agent)- What WEB does:
fetchCryptoInfo/updateEncryptionAtRestwith@type(Disabled|Aes128|Aes256),publicKey,encryptOnAppend,allowSpamTraining(spam training before encryption) andx:PublicKey/query|get|setCRUD withemailAddresses/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:
fetchEncryptionTypereturns only the@type(src/api/account-security.ts:150-155); shown read-only inAccountSecuritySettings.tsx:595. Nox:PublicKey/*, no set path. Per-account isolation is inherent (single-session client usesjmapClient.accountId). - Fix hint: port
updateEncryptionAtRest,fetchPublicKeys,createPublicKey,removePublicKeyintoaccount-security.ts(sameSTALWART_USING) and a key list/paste screen.
- What WEB does:
-
S/MIME settings screen is a mock; no S/MIME detection in the viewer —
P2—missing— fixed in06742ef(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:
SmimeSettingsrendersMOCK_KEYS/MOCK_CERTSand three settings that nothing reads (src/components/settings/SmimeSettings.tsx:32-61;src/stores/settings-store.ts:203-205,308-310), reachable from Settings asencryption(src/screens/SettingsScreen.tsx:130). Nothing detectsapplication/pkcs7-mime/multipart/signedbodies, so an encrypted message shows as an empty body with asmime.p7mattachment. - Fix hint: parity for RN core = (1) port
isSmimeEmailand 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 requirereact-native-quick-crypto(OpenSSL) plus a PKCS#7/CMS parser (pkijsruns 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.
- 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 (
-
Display name not refreshed from the Stalwart principal on login/restore/switch (#900) —
P3—bugfix-parity— fixed inb52768e- What WEB does: refreshes the account label from
x:Account/geton login, restore and switch (changelog 1.9.x "#900"; memory note: principal name is copied into the Identity only once). - What RN does:
fetchPrincipalexists (src/api/account-security.ts:157-181) but is only called from the security screen (AccountSecuritySettings.tsx:595);addAccountusesdisplayName: username(src/stores/auth-store.ts:187-195,123-131). - Fix hint: call
fetchPrincipal()(guarded byisStalwartSupported()) afterapplyConnectedStateandupdateAccount({displayName}).
- What WEB does: refreshes the account label from
-
Native issue #47 "Account security not accessible" - likely gating —
P3—rn-only-bug(unverified) — fixed in032732c- What RN does: the screen hides password/TOTP for Bearer sessions (
src/api/jmap-client.ts:66-68) and shows "not available" whenurn:stalwart:jmapis 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).
- What RN does: the screen hides password/TOTP for Bearer sessions (
Newsletter unsubscribe
- List-Unsubscribe not parsed or offered —
P2—missing— fixed ind2ed27f- What WEB does:
extractListHeadersparsesList-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 sendsmailto: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_PROPERTIESrequests no headers (src/api/email.ts:11-15); no parsing, no banner (grep -rln unsubscribe srchits only unrelated store code). - Fix hint: add
header:List-Unsubscribe:asTextandheader:List-Unsubscribe-Post:asTextto the full properties; portparseUnsubscribeUrls; for http+List-Unsubscribe=One-Clickdo the RFC 8058 POST (application/x-www-form-urlencoded, bodyList-Unsubscribe=One-Click) in-app, otherwise open the link; formailto:reusesendEmail.
- What WEB does:
Misc network
- Unified inbox detached fetch: no timeout, no 429/
maxConcurrentRequestshandling, N parallel sessions —P3—partial— fixed in6ae21d5- 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/fetchInboxForAccountdo a baresecureFetchper 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).
- What WEB does: per-account clients share the retry/timeout/rate-limit pipeline; unified fetch errors are collected per account (
Verified at parity (brief list, so the fixer knows what NOT to redo)
- Session discovery,
primaryAccountsselection (mail -> core -> first account), shared/group account detection (src/api/jmap-client.ts:388-401,480-493vslib/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
encodeURIComponentand RN-safe string splitting (nonew URL()), shared-accountaccountIdoverride (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/changesincremental sync withhasMoreChangesdrain andcannotCalculateChangesfallback; folder tree kept on failure (#780 behaviour) (src/stores/email-store.ts:528-612).Email/queryChanges+Email/changesincremental list refresh with snapshot guard (issue #10) and full re-query fallback (src/stores/email-store.ts:770-980).nullfor patch removal,falsestripped frommailboxIdsmaps (src/api/email.ts:456,551-556).- FilterOperator/FilterCondition kept separate in query filters (
src/api/email.ts:225-237). getSharedMailboxeschunks tomaxCallsInRequest(src/api/email.ts:84-105);getEvents/getContacts/offline sync chunk tomaxObjectsInGet, and list pages are capped at it sincequeryEmailPagereplacedfetchEmailsChunked(d987930).- Send:
/error,notCreatedchecked; HOLDFOR envelope for scheduled send; FUTURERELEASE capability check (src/api/email.ts:836-873,src/api/jmap-client.ts:505-531). Two gaps found later: the capability was read from the session instead of the account untile28e8b4(#57, audit B8), and a method error after a created submission (Stalwart's failed filing into Sent) was reported as a failed send untilc51a848(audit B23). - 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); calls that read or writeemailPushalso nameurn:ietf:params:jmap:emailpushsinceccaa24e, and every call checks the response sinceddfe85a. - 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, optimisticpatch, abort on account switch (src/stores/offline-cache-store.ts); the offline copy is shown before the network since33e303e(src/lib/email-detail-cache, which replacedgetEmailDetail). - 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 tohttps?|mailto|tel|sms(src/components/EmailBodyView.tsx:675-724). - No secrets in
console.*output;usesNonExemptEncryption: falsedeclared (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 propererror/notUpdatedchecks. - 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-srcchanges,srcDociframe 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 callsx:*methods directly. MAX_SSE_STREAMS/per-tab socket budget (#702), "one SSE stream per client", browservisibilitychange/onlineevents,document.hiddenpoll pausing: RN is single-session; the equivalent concern is AppState (listed as a gap).- Service-worker PWA caching,
blob:object URLs,URL.revokeObjectURL,window.openfor unsubscribe links. - WEB S/MIME plugin (privileged same-origin plugin tier),
onBeforeBlobUploadoffload hooks, plugin crypto API: no plugin system in RN. x-www-form-urlencodedTOTP token exchange on the WEB server (/api/auth/totp): RN verifies TOTP through Stalwart'sotpCodedirectly.