53 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 ina0ce925- 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. - 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 in0b1c240- 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- 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 in00faceb- 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- 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/fetchEmailsChunked/offline sync chunk tomaxObjectsInGet.- Send:
/error,notCreatedchecked; 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, optimisticpatch, abort on account switch (src/stores/offline-cache-store.ts);getEmailDetailcache 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 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.