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.
31 KiB
Authentication, login, session & multi-account
Summary
RN covers the happy paths (password login, webmail-mediated OAuth handoff, QR pairing, per-account keychain credentials, restore/switch/logout, Stalwart account-security API) and several WEB fixes are already mirrored (refresh coalescing, cached-token reuse, keep-session-on-network-error). The biggest gaps are: no TOTP/2FA password login at all (and the webmail handoff silently breaks for TOTP accounts), the Account Security screen is dead against every Stalwart server because it checks the wrong capability map (native issue #47), a failed "add account" or failed switch destroys the live session, expired/revoked tokens never lead back to the login screen, account display names are never refreshed (#900), relative session URLs are not resolved, and there is no per-account remove / per-device push revoke / public-key management.
Findings
Login flow
-
TOTP / 2FA password login is impossible —
P1—missing— fixed inb52768e- What WEB does: login form has a TOTP field (auto-shown when the server answers 402 "MFA code required",
lib/jmap/client.ts:956-966→TOTP_REQUIRED). With a code it calls/api/auth/totp-token-exchangewhich performs Stalwart's structured loginPOST {server}/api/auth {type:'authCode', accountName, accountSecret, mfaToken, clientId, redirectUri, codeChallenge, codeChallengeMethod:'S256'}thenPOST {server}/auth/token(authorization_code + PKCE) and continues as a bearer session (app/api/auth/totp-token-exchange/route.ts:26-28, 90-93, 125, 150;stores/auth-store.ts:651-706; changelog 1.7.5 "Support MFA login via the structured auth endpoint"). Legacypassword$totpbasic-auth fallback for pre-0.16 servers (auth-store.ts:696-702). - What RN does:
JMAPClient.connectonly does HTTP Basic with the raw password (src/api/jmap-client.ts:88-106,363-383). A 402 becomes"Session discovery failed: 402 …", whichdescribeLoginErroronly maps for 403/404 (src/lib/login-errors.ts:57) so the user sees a generic "Sign-in failed". The 401 copy tells 2FA users to create an app password (login-errors.ts:39-44) — that is the only workaround today. - Fix hint: detect 402 with a title containing
totp/mfainfetchSession, show a code step, then implement the two-step exchange directly on device (no CORS on native):/api/authwithmfaToken+clientId: 'bulwark-webmail'+ PKCE, then/auth/token; persist the result as an OAuth bundle (tokenEndpoint = ${server}/auth/token,clientId) soconnectWithOAuthand the existing refresh path take over.
- What WEB does: login form has a TOTP field (auto-shown when the server answers 402 "MFA code required",
-
Webmail password handoff breaks for TOTP-protected accounts —
P1—bug(cross-repo) — fixed in0d94d76(RN retries the handed-off password with a code prompt; the webmail-side flow=oauth hand-off is still open)- What WEB does: in mobile-handoff mode the login page always hands back
flow=passwordwith the raw password the user typed (app/(main)/[locale]/login/page.tsx:638-642, 282) even when the webmail itself upgraded that login to token auth via TOTP (stores/auth-store.ts:651-706,upgradedToOAuth). - What RN does:
loginViaWebmailfeeds the password intologin()→ Basic auth (src/stores/auth-store.ts:226-229) → Stalwart 402 → "Sign-in failed". The user completed 2FA in the browser and still cannot sign in. - Fix hint: WEB should hand off
flow=oauth(access/refresh token,token_endpoint,client_id) when the password login was TOTP-upgraded (the mobile SSO branch inapp/api/auth/sso/complete/route.ts:66-97already returns that shape); RN needs no change once WEB does that. Alternatively RN implements the previous finding and retries with a TOTP prompt when the handed-off password is rejected with 402.
- What WEB does: in mobile-handoff mode the login page always hands back
-
No direct OAuth/OIDC (PKCE) against the mail server — a Bulwark webmail at the JMAP origin is assumed —
P2—missing— fixed in4bebf70- What WEB does: discovers
/.well-known/oauth-authorization-serveroropenid-configuration(lib/oauth/discovery.ts:150-175), runs client-side PKCE (login/page.tsx:569-621,lib/oauth/pkce.ts) or server-side SSO; works against Stalwart's built-in OAuth (default client idbulwark-webmail,lib/oauth/token-exchange.ts:14), Keycloak, Authentik. - What RN does:
ConfirmStep → runHandoff(serverUrl)opens${serverUrl}/login?mobile_redirect_uri=…(src/lib/oauth.ts:49-56,src/screens/LoginScreen.tsx:128-135).serverUrlcomes from probing/.well-known/jmapondomain,mail.domain,webmail.domain(src/lib/server-discovery.ts:70-72, 143-170) — i.e. it finds the Stalwart host, and then assumes a Bulwark webmail lives at that same origin. When webmail is on another host (or the server only runs Stalwart), the browser opens a 404/Stalwart admin page and the only way in is the password step. - Fix hint: either (a) do PKCE natively with
expo-web-browser/expo-auth-sessionagainst the discoveredauthorization_endpoint(redirectbulwarkmobile://auth/callback, client idbulwark-webmail, scopesopenid email profile), or (b) probe${serverUrl}/api/configbefore opening the handoff and ask for the webmail address when it is not a Bulwark instance.
- What WEB does: discovers
-
Login error classification is thinner than WEB —
P3—partial— fixed inb52768e- What WEB does:
classifyLoginErrormaps invalid credentials, network, 5xxserver_error,totp_required; 429 is aRateLimitErrorwith retry-after (stores/auth-store.ts:71-96,lib/jmap/client.ts:849-853). - What RN does:
describeLoginError(src/lib/login-errors.ts) has no 402 (MFA), 429 or 5xx branches;RateLimitError('Rate limited by server')falls to the generic "Sign-in failed" with the raw message. - Fix hint: add branches for
402→ 2FA copy,429/RateLimitError→ "try again in N s",5xx→ "server error, try later".
- What WEB does:
-
Saved-username suggestions on the login form —
P3—missing— fixed in2404717- What WEB does: remembers the last 5 usernames and offers them as an autocomplete (
login/page.tsx:500-517). - What RN does:
EmailStep/PasswordStepstart empty; in add modeChooseSteponly offers the known server, not known usernames. - Fix hint: prefill/offer
accounts[].usernamefrom the registry (and remember the last typed address in AsyncStorage) inEmailStep.
- What WEB does: remembers the last 5 usernames and offers them as an autocomplete (
-
Login flow strings are hard-coded English —
P3—partial— fixed in2404717- What WEB does: every login string is localized (
login.*keys used throughoutlogin/page.tsx). - What RN does:
LoginScreen.tsx, allsrc/screens/login/*.tsx,QrScanModal.tsxandlogin-errors.tsuse literal English whileSettingsScreenalready usesuseLocaleStore().t(src/screens/SettingsScreen.tsx:169-190). - Fix hint: route through
t()withlogin.*keys (the i18n audit may cover this; listed here so it is not lost).
- What WEB does: every login string is localized (
-
QR hint copy points to a settings page that does not exist —
P3—bug— fixed inb52768e- What WEB does: the pairing QR is generated in Settings → Security ("Link device",
components/settings/account-security-settings.tsx:894-1026). - What RN does: hints say "Settings → Devices → Add phone" (
src/components/QrScanModal.tsx:66) and "Settings → Devices" (src/screens/LoginScreen.tsx:221). - Fix hint: change the copy to Settings → Security → Link device.
- What WEB does: the pairing QR is generated in Settings → Security ("Link device",
-
Client TLS certificate support is Android-only and does not cover push/SSE —
P3—partial(native issue #3) — fixed inedc26ce(live updates fall back to polling with a cert; redirects handled in JS (6f747db); iOS implementation still deferred)- What WEB does: N/A (the browser negotiates mTLS).
- What RN does:
src/lib/client-cert.tsbridges an AndroidBulwarkClientCertmodule andsecureFetchis used by the JMAP client, blob and discovery code. Gaps: iOS has no implementation (getNativereturns null off Android,client-cert.ts:34-45); the JMAP SSE stream usesEventSourcewith a plain header (src/api/push.ts:120-137) so live updates fail behind mTLS; the system browser used for the webmail handoff cannot present the cert; the abort signal does not reach the native path (server-discovery.ts:103-135works around it). - Fix hint: document the limits in the native issue; route SSE through a native fetch-based reader or the
react-native-ssefetch option when a cert alias is set; consider an iOSURLSessiondelegate implementation.
Session
-
Relative session URLs are not resolved —
P2—bugfix-parity(changelog 1.9.0 "Resolve relative session URLs without corrupting URI templates") — fixed in0b1c240(unified-inbox.ts copy is with the mail-list agent)- What WEB does:
rewriteSessionUrlprefixes the connected origin whenapiUrl/downloadUrl/uploadUrl/eventSourceUrlhave no scheme, and rewrites the origin of absolute ones without touching{accountId}/{blobId}templates (lib/jmap/client.ts:1091-1110). - What RN does:
rewriteSessionUrlsonly rewrites absolute URLs;extractOriginreturns null for/jmap/so the relative value is passed through unchanged andsecureFetch('/jmap/')fails (src/api/jmap-client.ts:320-340). Same in the unified inboxrewriteApiUrl(src/api/unified-inbox.ts:55-60). - Fix hint: when the URL has no
^https?://, returnserverOrigin + (url.startsWith('/') ? url : '/' + url); keep the plain string splitting (the comment about the RN URL polyfill corrupting templates still applies).
- What WEB does:
-
Redirected session fetch may lose the Authorization header —
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:
fetchSessionResponsedetects a redirected 200 with no accounts/username (Safari and some auth proxies dropAuthorizationon redirect) and refetchesresponse.urlwith the header (lib/jmap/client.ts:911-925). - What RN does:
fetchSessiontrusts the redirected response (src/api/jmap-client.ts:363-383); Stalwart 0.16.19 answers/.well-known/jmapwith a 307 to/jmap/session(verified against stw-test19), and iOSNSURLSessioncan strip the header on redirect, which ends inresolveAccountIdthrowing "No account found in JMAP session". - Fix hint: mirror the WEB check (
response.redirectedand emptyaccounts/username→ refetchresponse.urlwith the header).
- What WEB does:
-
Transient token-endpoint failure evicts the account —
P2—bugfix-parity(changelog 1.7.6 "Keep the session when the auth server is briefly unreachable", 1.7.8 "End refresh loops on sign-out and back off failed retries") — fixed in2c0dbd1- What WEB does: only a definitive 400/401/403 from the token endpoint drops the refresh token (
app/api/auth/token/route.ts:143); 5xx/network → 503 and the client keeps the account and retries with backoff (stores/auth-store.ts:1209-1226,1792-1799,isTransientAuthErrorat:106). - What RN does:
refreshOAuthAccessTokenthrowsHandoffErrorfor any non-OK status or network error (src/lib/oauth.ts:239-241);forceRefreshTokenturns every failure intofalse(src/api/jmap-client.ts:211-221) so an expired access token + a 5xx/offline token endpoint becomesAuthenticationError, andrestoreSession/switchAccount/retrySessionthen delete the keychain entry (src/stores/auth-store.ts:397-405, 505-512, 569-586). - Fix hint: in
refreshOAuthAccessTokenthrow a distinct transient error for network failures, 429 and 5xx; infetchSession/requestrethrow it asNetworkErrorinstead ofAuthenticationError; only 400/401/403 from the token endpoint are definitive.
- What WEB does: only a definitive 400/401/403 from the token endpoint drops the refresh token (
-
A revoked/expired refresh token never leads back to the login screen —
P2—rn-only-bug— fixed inb52768e- What WEB does: a 401 from refresh marks the session expired, logs out and redirects to login with a "session expired" banner (
stores/auth-store.ts:1209-1214,markSessionExpired,login/page.tsxsession_expiredbanner). - What RN does:
request()throwsAuthenticationError('Session expired')(src/api/jmap-client.ts:433) but nothing outsideauth-store/login-errorshandles that class (grep oversrc/); the email store just records the message.retrySessionreturns early becauseget().sessionis still the stale object (src/stores/auth-store.ts:554). The user is stuck on a dead session until a relaunch. - Fix hint: give
JMAPClientanonAuthFailurecallback (set by auth-store) that runs theAuthenticationErrorbranch ofretrySession(clear creds for the active account,isAuthenticated:false, error "Session expired"); or clearsessionin the store on that error soretrySessioncan run.
- What WEB does: a 401 from refresh marks the session expired, logs out and redirects to login with a "session expired" banner (
-
Logout does not revoke the OAuth refresh token —
P3—partial— fixed in4bebf70(pairing bundles are deliberately not revoked)- What WEB does:
DELETE /api/auth/tokenposts the refresh token to the IdPrevocation_endpointon logout/remove/logout-all (app/api/auth/token/route.ts:181-254). - What RN does:
logout/logoutAllonly delete the SecureStore entry (src/stores/auth-store.ts:286-357,src/api/jmap-client.ts:350-361); the refresh token stays valid server-side. - Fix hint: discover
revocation_endpointfrom${serverUrl}/.well-known/oauth-authorization-serverand POSTtoken=<refresh>&token_type_hint=refresh_token&client_id=…best-effort. Caveat: a QR-paired phone shares the desktop's refresh token (app/api/auth/pair/create/route.ts:18-22), so revoking it would also sign out the desktop — only revoke bundles that came from the browser handoff, or accept that behaviour deliberately.
- What WEB does:
-
No request deadline on JMAP calls —
P3—partial(changelog 1.8.1 "Time out stalled JMAP requests so a send can't hang forever (#702)") — fixed in0b1c240- What WEB does:
timedFetchaborts when no response headers arrive within 30 s (300 s for blob transfers) and does not retry timed-out non-idempotent requests (lib/jmap/client.ts:786-816). - What RN does:
request()/fetchSessionuse baresecureFetchwith no timeout (src/api/jmap-client.ts:363-383, 410-445); only the native-cert path (30 s) and the discovery probe (2.5 s) have deadlines. - Fix hint: add an
AbortControllertimeout inrequest()(and blob helpers), surfacing a distinct timeout error; do not auto-retry sends.
- What WEB does:
-
SSE stream is opened without a token freshness check —
P3—partial(verify with the push audit) — fixed inedc26ce- What WEB does: SSE uses
authenticatedFetch, so a 401 triggers the refresh path. - What RN does:
connectEventSourcereadsjmapClient.authHeaderonce (src/api/push.ts:137) withoutensureFreshToken(private,src/api/jmap-client.ts:195), so a nearly-expired access token can be used for a long-lived stream. - Fix hint: expose an
ensureFreshToken()-then-header helper on the client and call it before connecting/reconnecting the stream.
- What WEB does: SSE uses
Multi-account
-
A failed "add account" destroys the active session —
P1—rn-only-bug— fixed inb52768e- What WEB does: connects the new account with a fresh
JMAPClientand only snapshots/clears the previous account afterconnect()succeeded (stores/auth-store.ts:709-716,957-962). - What RN does:
login()andcompleteOAuthHandoffcalljmapClient.reset()and reset contacts/calendar before connecting (src/stores/auth-store.ts:110-114, 175-180). On failure the store still says the previous account is active, but the singleton now holds the wrong credentials and no session; cancelling the modal (App.tsx:470-481) restores nothing, so every request throws "Not connected" until relaunch. - Fix hint: connect with a throwaway
new JMAPClient()(or keep the current creds/session in locals) and only adopt the new connection into the singleton after success; on failureawait jmapClient.loadAccount(previousActive).
- What WEB does: connects the new account with a fresh
-
A failed account switch leaves the client half-switched —
P2—rn-only-bug— fixed inb52768e- What WEB does: on restore failure the previous account's client is re-activated (
stores/auth-store.ts:1546-1565); rate-limited/transient failures keep both accounts. - What RN does:
loadAccount(target)overwritescredentialsand nullssession/_accountIdon failure (src/api/jmap-client.ts:244-273);switchAccountrestores only the email-store view (src/stores/auth-store.ts:393-411) and leaves the store'ssessionset, soretrySessionshort-circuits (:554) and the previous account is dead. - Fix hint: in the catch,
await jmapClient.loadAccount(previousActive)(or restore the saved in-memory creds/session) before returning; alternatively setsession: nullso the network-recovery watcher re-establishes it.
- What WEB does: on restore failure the previous account's client is re-activated (
-
Account display name is never refreshed from the server (#900) —
P2—missing(changelog 1.9.0 "Refresh the account display name from the Stalwart principal on login, restore, and switch") — fixed inb52768e- What WEB does: seeds
displayName/emailfrom the primary identity at login, thensyncAccountDisplayNamereadsx:Account/get(principal "Full name") on login, restore and switch and updates the registry (stores/auth-store.ts:146-176, 741-751;lib/stalwart/principal.ts). - What RN does:
addAccount({ displayName: username, email: username })(src/stores/auth-store.ts:123-131, 186-193) and nothing ever updates it; the drawer header shows the raw username (src/components/SidebarDrawer.tsx:297).fetchPrincipalalready exists (src/api/account-security.ts:157-176) but only feeds the security screen, and saving a new display name there does not touch the registry. - Fix hint: after each successful connect run
Identity/get(name/email) and, whenurn:stalwart:jmapis in the account'saccountCapabilities,fetchPrincipal();accountStore.updateAccount(id, { displayName, email }); also update onupdateDisplayNamesuccess.
- What WEB does: seeds
-
OAuth account id/email derived from the raw JMAP username —
P3—partial— fixed inb52768e(email refreshed from the primary identity; registry id unchanged)- What WEB does: prefers the primary identity email over
Session.usernamefor OAuth/SSO because OIDCpreferred_usernamemay not be an address (stores/auth-store.ts:946-951, 1096). - What RN does:
connectWithOAuthusessession.username || accessToken.slice(0, 8)(src/api/jmap-client.ts:135-136), so the same mailbox logged in via password and via OAuth can produce two registry entries, and the drawer synthesizesusername@host. - Fix hint: read
Identity/getafter connect and use the primary identity email foremail(and ideally for the registry id).
- What WEB does: prefers the primary identity email over
-
Cannot remove a non-active account —
P2—missing(changelog 1.7.8 "Remove a specific account from the switcher") — done in6ae21d5,490355c- What WEB does: per-row remove button in the switcher (
components/layout/account-switcher.tsx:138-143, 322-333) backed byremoveAccountwhich tears down the client, evicts caches and clears cookies (stores/auth-store.ts:1382-1401). - What RN does: only "Sign out of " and "Sign out all" (
src/components/SidebarDrawer.tsx:400-437). - Fix hint: add a per-row remove (long-press or trailing X) that runs
teardownPushNotificationsForAccount(id),jmapClient.clearAccountCredentials(id),useEmailStore.removeAccount(id),accountStore.removeAccount(id).
- What WEB does: per-row remove button in the switcher (
-
Switcher shows stale connection state and no error indicator —
P3—partial— done in6ae21d5(the drawer showshasErrorwith its message)- What WEB does: shows
hasError(alert icon) witherrorMessageand a liveisConnecteddot per account (account-switcher.tsx:296-303). - What RN does: renders only the
isConnecteddot (SidebarDrawer.tsx:355-361);hasError/errorMessage(set inrestoreSession) are never displayed, and since only the active account is ever connected, other rows show whatever value they had last. - Fix hint: render
hasError; show the dot only for the active account (or treat inactive as "cached").
- What WEB does: shows
-
Accounts list in Settings (reorder, set default, add) is missing — fixed in
490355c—P3—missing(changelog 1.7.2 "List and reorder logged-in accounts from settings (#282)", 1.7.7 "Pin the default account on top and drag-to-reorder") — move up/down + set default + remove + add; account-store.reorderAccounts- What WEB does:
AccountSettingslists all accounts with move up/down, drag reorder, set-default, add (components/settings/account-settings.tsx:182-220); switcher pins the default first (lib/account-utils.ts:117-142). - What RN does:
AccountSettingsshows only the active account's fields (src/components/settings/AccountSettings.tsx); the drawer lists accounts in registry order with set-default only (SidebarDrawer.tsx:390-406). - Fix hint: add an accounts section with set-default/remove/reorder (
accountStoreneeds areorderAccounts).
- What WEB does:
-
AccountSettings reports the wrong auth method and never shows storage — fixed in
490355c—P3—rn-only-bug— jmapClient.usesBearerAuth + Quota/get (src/api/quota.ts)- What WEB does: shows OAuth vs Basic from
authModeand a quota bar (account-settings.tsx:151-180). - What RN does:
authMode = props.authMode ?? 'basic'andquotaUsed = props.quotaUsed ?? 0(AccountSettings.tsx:46-47) butSettingsScreenrenders<Component />with no props (SettingsScreen.tsx:242), so OAuth accounts read "Basic" and the storage row never appears (RN has noQuota/getat all — flag for the mail/settings audit). - Fix hint: derive from
jmapClient.usesBearerAuth(or persistauthModeonAccountEntry); fetch quota viaQuota/getwhenurn:ietf:params:jmap:quotais advertised.
- What WEB does: shows OAuth vs Basic from
-
Hard account cap of 5 —
P3—partial— done in6ae21d5(cap raised to 10,src/lib/account-utils.ts:4)- What WEB does: 5 on HTTP/1.1, lifted to 50 when HTTP/2 is observed, because each web account pins an SSE socket (
lib/account-utils.ts:75-107). - What RN does:
MAX_ACCOUNTS = 5(src/lib/account-utils.ts:1) although only the active account holds a live connection. - Fix hint: raise the constant (or drop the cap) once the unified inbox cost per account is acceptable.
- What WEB does: 5 on HTTP/1.1, lifted to 50 when HTTP/2 is observed, because each web account pins an SSE socket (
-
Shared/group account settings scope is missing —
P3—missing(changelog 1.7.5 "Manage shared/group account settings from the Accounts page") — partly: shared accounts are listed in AccountSettings (490355c), and tapping one scopes Filters and Vacation to it since a86cd1d; calendar and contacts settings are not scoped yet- What WEB does: lists
client.getSharedAccounts()(non-primary) on the Accounts page and enters a scoped settings mode (filters, vacation, calendars, contacts) viamanaged-account-store(components/settings/account-settings.tsx:43-48, 112-118, 225-262;stores/managed-account-store.ts). - What RN does:
getSharedMailAccounts()exists for mail/unified inbox (src/api/jmap-client.ts:480-495) but there is no shared-account listing or scoped settings. - Fix hint: list non-personal
session.accountsinAccountSettingsand pass anaccountIdinto the filter/vacation stores.
- What WEB does: lists
-
Unified-inbox shared-account filter disagrees with
getSharedMailAccounts—P3—rn-only-bug— done in6ae21d5- What WEB does: a non-personal account counts as mail-capable even when its
accountCapabilitiesomit mail (lib/jmap/client.ts:4421-4445); RN's owngetSharedMailAccountsmirrors that (jmap-client.ts:486-493). - What RN does: the unified inbox requires
accountCapabilities == null || MAIL in accountCapabilities(src/api/unified-inbox.ts:208-210), so a shared account that advertises other capabilities but not mail is silently skipped there while it appears in the sidebar. - Fix hint: reuse the
!advertisesMail && info.isPersonal → skiprule fromgetSharedMailAccounts.
- What WEB does: a non-personal account counts as mail-capable even when its
Account security settings
-
Account Security screen is dead against every Stalwart server (native issue #47) —
P1—rn-only-bug— fixed in032732c- What WEB does: probes
client.hasAccountCapability('urn:stalwart:jmap'), i.e. the account-level capability map (stores/account-security-store.ts:276-290,lib/jmap/client.ts:4173-4177,lib/stalwart/principal.ts:33). - What RN does:
isStalwartSupported()checks the session-levelsession.capabilities(src/api/account-security.ts:51-54). Verified against Stalwart 0.16.19 (stw-test19):urn:stalwart:jmapis present only underaccounts[<id>].accountCapabilities, not in the top-levelcapabilities. SoAccountSecuritySettingsalways renders "Account security management requires a Stalwart server" (src/components/settings/AccountSecuritySettings.tsx:584, 622). The same effect also shows that copy when the session is merely offline (:583). - Fix hint:
const acc = session.accounts?.[jmapClient.accountId]; return !!acc?.accountCapabilities?.[STALWART_CAPABILITY] || STALWART_CAPABILITY in (session.capabilities ?? {}); show an "offline" notice instead of the Stalwart notice whencurrentSessionis null.
- What WEB does: probes
-
Push subscriptions: no per-device list/revoke and no forced recreate (#841) — fixed in
f6f26f7—P2—missing(changelog 1.9.0 "Per-device revoke for push subscriptions (#841)", "Recreate the push subscription on re-register (#841)")- What WEB does: lists relay devices with a "this device" marker and per-device revoke, and Re-register passes
forceRecreateso a stale subscription is recreated rather than its expiry refreshed (components/settings/notification-settings.tsx:95, 115-131, 169). - What RN does: Enable / Re-register / Disable for this device only (
src/components/settings/NotificationSettings.tsx:81, 186-198); no device list, and whether Re-register recreates the JMAPPushSubscriptionwas not verified here (push audit). - Fix hint: call the relay device-list endpoint the web uses, render rows with revoke (
PushSubscription/set destroy+ relay unregister), and thread aforceRecreateflag throughsetupPushNotifications.
- What WEB does: lists relay devices with a "this device" marker and per-device revoke, and Re-register passes
-
Public keys (S/MIME/PGP) and encryption-at-rest configuration are missing — fixed in
ddae1e6—P3—missing(changelog 1.8.1 "Manage S/MIME and PGP public keys and configure Stalwart encryption at rest from account security settings")- What WEB does:
PublicKeysSectionwithx:PublicKey/query|get|setandx:AccountSettings/set encryptionAtRest {@type, publicKey, encryptOnAppend, allowSpamTraining}(components/settings/account-security-settings.tsx:573-840;stores/account-security-store.ts:397-455, 625-700). - What RN does: read-only
EncryptionSectionshowing the@typeonly (AccountSecuritySettings.tsx:539-555;src/api/account-security.ts:150-155). - Fix hint: port the four store methods to
src/api/account-security.tsand add a key list + encryption picker.
- What WEB does:
-
OAuth
stateis generated withMath.random—P3—rn-only-bug— fixed in2c0dbd1- What WEB does:
crypto.getRandomValuesfor verifier/state (lib/oauth/pkce.ts:11-27). - What RN does:
randomState()usesMath.random(src/lib/oauth.ts:41-46) although a CSPRNG helper withgetRandomValues/randomUUIDfallbacks already exists insrc/lib/totp.ts:16-46. The state is the only guard against a forgedbulwarkmobile://redirect delivering foreign credentials. - Fix hint: export
randomBytesfromtotp.ts(or a sharedrandom.ts) and use it here.
- What WEB does:
Verified at parity (brief list, so the fixer knows NOT to redo)
- QR login payloads: WEB emits
bulwarkmail://pair?server=<webmailBase>&code=…(account-security-settings.tsx:971); RN parses it plus aconnectvariant and bare URLs (src/lib/oauth.ts:135-162) and redeems at/api/auth/pair/redeem(:165-207) into the same OAuth bundle the browser handoff yields. - Webmail handoff (
mobile_redirect_uri/mobile_state, fragment transport, state check, password/oauth flows) matcheslogin/page.tsx:115-130, 282, 638-650andauth/callback/page.tsxmobile branch. - Token refresh: coalesced per refresh token (
src/lib/oauth.ts:210-262, WEBrefreshPromises); cached access token reused until 60 s before expiry (#552;jmap-client.ts:195-209); client id always carried in the bundle so #873 does not apply; rotated refresh tokens persisted. - Session restore: waits for AsyncStorage hydration, restores active → default account, keeps credentials and shows cached data on
NetworkError(mirrors 1.7.6 "keep the session when the auth server is briefly unreachable"), evicts on definitiveAuthenticationError(mirrors 1.6.0 "evict unrecoverable basic-auth accounts"), legacy single-slot migration. - Logout: revokes this account's push subscription first, clears only that account's caches, switches to default/next account or falls back to the login screen; "Sign out all" wipes everything.
- Switch: email store is swapped before the network round-trip so the cached list shows immediately (same intent as 1.7.8 "eliminate the full-screen flash when switching accounts").
- Shared/group discovery:
getSharedMailAccountsapplies the WEB rule (non-personal OR advertises mail); unified inbox namespaces bysourceAccountId/jmapAccountId. - Account security API (password change, display name, TOTP enrol/disable with
notUpdatedsurfaced — 1.7.5 "Surface server errors on password change and TOTP toggle", app passwords, API keys with expiry/allowed IPs, principal/encryption reads,Promise.allSettledfor forbidden principal reads) mirrorsstores/account-security-store.ts; password/TOTP/display-name sections hidden for bearer sessions like WEBisOAuth. - Server discovery by email domain (
/.well-known/jmapprobe on bare/mail./webmail.hosts, 401 counts as a hit, known servers trusted) is RN's equivalent of WEB's admin server list / auto-pick-by-domain (#799); neither side does_jmap._tcpSRV. - Login error → session-expired banner: RN's
ChooseStepshows the store error ("Session expired") like WEB'ssession_expiredbanner. AuthenticationErroron 401,RateLimitErroron 429 with Retry-After parsing inrequest().
N/A on mobile
- "Remember me" (RN always stores credentials in the device keychain),
rememberMeEnabled/SESSION_SECRETcookie logic, per-slot cookie handling (cookieSlot,oauth_cookie_slot), orphan-cookie adoption,serverIdentifiers/classifySessionMatchslot→token desync guard (RN keys credentials per registry id, no shared slot). - Server-configured login branding/notice (
LOGIN_SHOW_TOTP,LOGIN_SHOW_VERSION, logos, imprint/privacy links, company name), OAuth-only mode / auto-SSO / iframe embedded SSO (#69), server picker dropdown (#799), custom JMAP endpoint toggle, demo mode, dev-mode login, CORS error classification. - Impersonation (
/api/auth/impersonate,%mastersuffix stripping, impersonation reconciler), admin login. - TOTP re-auth dialog (
totp-reauth-store/totp-reauth-dialog): only used by WEB's legacypassword$totpbasic-auth path for pre-0.16 Stalwart; once RN gains TOTP via the token exchange no re-auth is needed. - "Link device" QR generator (RN is the device being linked); TOTP enrolment QR image (same-device — RN's
otpauth://deep link + manual secret is the right shape). - 30 s keep-alive ping /
connectionLoststate (RN uses NetInfo +retrySession), per-tab SSE budget (#702), settings-sync enable/disable on login (settings audit), Stalwart passthrough auth context (/api/auth/stalwart-context), plugin logout hooks.