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.
43 KiB
Email viewer / thread view / message rendering / attachments
Summary
RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pinch zoom, external-content gate with trusted senders, cid inlining, attachment open/share/save with templated filenames, view source, .eml export, calendar-invitation banner, shared-folder routing for body/attachments/source) but it is a single-message viewer, not a thread view, and it never fetches headers/messageId/inReplyTo/references, so everything the WEB viewer derives from headers (SPF/DKIM/DMARC, List-Unsubscribe, read receipts, Reply-To, identity badge, correct In-Reply-To) is absent or wrong. The biggest gaps: (1) older messages of a thread are unreachable and the pager shows the wrong message when opened from the unified inbox / contact activity; (2) replies write the JMAP id into In-Reply-To, ignore Reply-To, do not strip the user's own address on reply-all, and forwards drop every attachment; (3) HTML-only messages (same part in textBody and htmlBody) are rendered and quoted as raw HTML source, and <style> blocks are stripped wholesale, which is the most likely root of native issues #46 ("email preview shows css") and #49 ("Unreadable text in dark-mode emails"); (4) external-content blocking only recognises <img src=http…>, background= and inline url(), so srcset/<source>/poster/whitespace-obfuscated pixels still load.
Findings
Thread view / navigation
-
Thread messages other than the newest are unreachable — fixed in
d2ed27f—P1—missing- What WEB does: opening a thread renders every message of the thread as expandable cards (newest + all unread auto-expanded), each with its own body, attachments, reply/forward and mark-read-on-expand (
components/email/thread-conversation-view.tsx:98-115,171-200, storefetchThreadEmailsviaThread/get+Email/getinstores/email-store.ts:3574-3640,lib/jmap/client.ts:2763-2800). - What RN does: the list collapses same-thread rows to the newest message with a count badge (
src/screens/EmailListScreen.tsx:224-238, defaultdisableThreading: falseatsrc/stores/settings-store.ts:266), the row opensEmailThreadScreenwith a singleemailId(App.tsx:145-151), and the screen only ever shows that one message (src/screens/EmailThreadScreen.tsx:113-120,685-840).getThreadexists insrc/api/email.ts:448but is only used by a test. Older messages of a thread cannot be opened at all unless the user turns on "Disable Thread Grouping". - Fix hint: on open, call
getThread(threadId, ownerAccountId)+getFullEmails(ids)and render a vertical list of collapsible message cards inside the pane (newest + unread expanded), reusingEmailBodyViewper card; keep the horizontal pager for thread-to-thread navigation.
- What WEB does: opening a thread renders every message of the thread as expandable cards (newest + all unread auto-expanded), each with its own body, attachments, reply/forward and mark-read-on-expand (
-
Pager shows the wrong message (or nothing) when the opened email is not in the store list — fixed in
c8be383—P1—rn-only-bug— the body was right afterc8be383, but delete, archive, move and spam hit the open folder's account, and new mail moved the page, until5c7301band9c31de2(audit B3, B5)- What WEB does: the viewer renders
selectedEmaildirectly; prev/next are derived from the current list (components/email/email-viewer.tsx:5220-5279). - What RN does: the pager is a
FlatListoveruseEmailStore(s => s.emails)and the initial index isMath.max(0, emails.findIndex(id))(src/screens/EmailThreadScreen.tsx:128-133,535-572).UnifiedInboxScreenkeeps its own local list and never writes the store (src/screens/UnifiedInboxScreen.tsx:35-50,61-84),ContactActivitynavigates with ids from a contact search (src/components/contacts/ContactActivity.tsx:170-177). In both cases the opened id is absent fromemails, so the visible pane isemails[0](a different mailbox's first message, or blank when the list is empty) while the toolbar/mark-read/delete act onactiveEmailId. - Fix hint: when
route.params.emailIdis not inemails, render a single-item pager (data=[{id: emailId}]) and disable prev/next; or have the unified inbox / contact activity pass their own id list through route params.
- What WEB does: the viewer renders
-
Mark-as-read "Never" (-1) marks the message read instantly — fixed in
d2ed27f—P2—rn-only-bug- What WEB does:
-1means never auto-mark,0instant,>0delayed (components/email/email-viewer.tsx:1218-1238). - What RN does:
ReadingSettingsoffersNever = -1(src/components/settings/ReadingSettings.tsx:167-173) but the viewer doesif (markAsReadDelay > 0) {timer} else { markRead() }(src/screens/EmailThreadScreen.tsx:237-245), so-1behaves like instant. - Fix hint: add
if (markAsReadDelay === -1) return;before the branch.
- What WEB does:
-
Skeleton / loading parity — verified, no action —
P3—verified(see "Verified at parity"); no action.
Body rendering & sanitisation
-
HTML-only messages are rendered (and quoted) as raw HTML source — native issue #46 "email preview shows css" — fixed in
06742ef—P1—bug- What WEB does: per RFC 8621 §4.1.4 an HTML-only message exposes the same part in
textBodyandhtmlBody; WEB checkshasDistinctTextBody(different partIds) before preferring the text alternative, and routes by the part'stype(components/email/email-viewer.tsx:1727-1747,components/email/thread-conversation-view.tsx:332-346; changelog 1.6.3 "Render HTML-only emails", "Render plain-text-only emails as text, not HTML"). - What RN does:
extractTextBodyreturns the html part's value for HTML-only mail, thenrawHtml = html && (!text || hasMeaningfulHtmlBody(html)) ? html : null(src/components/EmailBodyView.tsx:43-57,504-511). For any HTML-only message whose markup failsMEANINGFUL_HTML_RE(src/lib/email-html.ts:353-362, e.g. a<div>/<p>body withoutstyle=),rawHtmlis null and the raw source — tags,<head>, CSS — is shown as plain text. The same value feeds the reply quote:plainTextBody()(src/screens/EmailThreadScreen.tsx:59-65,377) so replies to HTML-only mail quote escaped HTML source. - Fix hint: compare
textBody[0].partId !== htmlBody[0].partId(andpart.type === 'text/html') before treatingtextas an alternative; innavigateComposepasshtmlBody(and letbuildInitialHtmluse it) or convert withhtmlToPlainText(src/lib/compose-html.ts:108) when the only body is HTML.
- What WEB does: per RFC 8621 §4.1.4 an HTML-only message exposes the same part in
-
<style>blocks are stripped from every HTML email — layout/colour loss, likely native issue #49 "Unreadable text in dark-mode emails" — fixed in06742ef—P2—bugfix-parity- What WEB does: iframe sanitiser keeps
<style>(lib/email-sanitization.ts:60-78), and when external content is blocked it neutralisesurl()/@import/@font-faceinside the sheet (stripExternalStyleSheetCss,lib/email-sanitization.ts:474-509, changelog 1.7.8 #457) while the strict iframe CSP is the network backstop (components/email/email-viewer.tsx:2265-2267). - What RN does:
FORBID_TAGSincludes'style'(andsvg) so every stylesheet is removed before render (src/lib/email-html.ts:16-28,50-68). Class-based colours vanish: a newsletter with white text via.hero { color:#fff }on abgcolorcell renders white-on-white (light) or, after the invert filter, black-on-black. AlsohasNativeDarkModeis evaluated on the stripped HTML insidewrapEmailHtml(src/lib/email-html.ts:226-229) but on the raw HTML in the component (src/components/EmailBodyView.tsx:612-613), so for emails with@media (prefers-color-scheme: dark)the CSS inversion is applied but the DOM re-invert pass is skipped. - Fix hint: keep
<style>(the WebView is an isolated document; CSP already hasstyle-src 'unsafe-inline'), portstripExternalStyleSheetCss+decodeCssEscapesfor the blocked case, and evaluatehasNativeDarkModeonce on the same input.
- What WEB does: iframe sanitiser keeps
-
Dark-mode inversion CSS is an older revision of WEB's — fixed in
d2ed27f—P2—bugfix-parity- What WEB does (1.7.4 "Correct dark-mode background-image inversion and height clipping", 1.6.3 emoji/nested bgcolor, 1.7.7
height:100%wrappers): background-image containers are re-inverted unconditionally and their media getfilter:none; only bgcolor/background:containers use the:not(:has(media))guard; nested guard is limited to colour containers;[style*="height:100%"]is neutralised; Word/Outlook HTML getsline-height:1.15+MsoNormalmargins and a fallback gutter;messageSpacingauto/edge gutter (components/email/email-viewer.tsx:2200-2253,2287-2305,2438-2461). - What RN does:
DARK_INVERSIONapplies the:not(:has(...))guard to background-image containers too and includes them in the nestedfilter:nonerule (src/lib/email-html.ts:167-185), the DOM pass only re-inverts containers without media children (src/components/EmailBodyView.tsx:72-88); no Word/Outlook padding, no per-message light/dark toggle (WEB toolbar Sun/Moon,email-viewer.tsx:3199-3212, changelog 1.6.0), nomessageSpacing. - Fix hint: copy the current
darkModeCSSblock and the DOM re-invert loop verbatim intoemail-html.ts/DARK_REINVERT_SCRIPT; add a "View in light/dark mode" entry to the More sheet that overridesrenderAsDarkfor the current message.
- What WEB does (1.7.4 "Correct dark-mode background-image inversion and height clipping", 1.6.3 emoji/nested bgcolor, 1.7.7
-
External-content detection misses most tracking vectors (privacy leak) — fixed in
06742ef—P2—bugfix-parity- What WEB does:
isExternalResourceUrlstrips C0/space so"\nhttps://x"andh\ttps://count; blocks<img srcset>,<picture><source>,<video poster>, mediasrc,background=, inlineurl()incl. CSS escapes,<style>url()/@import, and forbids<link>in blocking mode; the banner is driven by "something was actually blocked" (lib/email-sanitization.ts:399-406,511-615,108-150; changelog 1.7.8). - What RN does:
hasRemoteContentonly matches<img src="http…">,background="http…"andstyle="...url(http…)"(src/lib/email-html.ts:364-369); when it returns falseshouldBlockis false and the CSP becomesimg-src data: cid: https: http:(src/lib/email-html.ts:235), so a pixel expressed assrcset=,<source>,poster=,src=" https://…"(leading whitespace/newline), or a CSS-escaped\68ttps://loads unconditionally under policyask/block.blockRemoteImageSrcs(316-347) is likewise src/srcset/background/style only, and has nodata-blocked-*restore. - Fix hint: make the gate policy-driven rather than detection-driven (block = strict CSP
img-src data: cid:+media-src/font-srcnone whenever policy isblock/ask-not-allowed and sender untrusted, like WEB'sexternalBlocked), portisExternalResourceUrlnormalisation, addsource/video/audio/poster/linkhandling, and show the banner when the strict CSP is in effect and the HTML contains anyhttp(s)://resource reference.
- What WEB does:
-
Blocked images leave broken-image icons / alt text; empty containers not collapsed — fixed in
06742ef—P3—bugfix-parity- What WEB does: swaps
srcfor a transparent 1x1 GIF, emptiesalt,display:none, and collapses empty<td>/<div>wrappers (lib/email-sanitization.ts:319-320,537-546,678-704); hides images that fail to load (components/email/email-viewer.tsx:2393-2411, changelog 1.7.7). - What RN does: sets
src=""andalt="[remote image blocked]"(src/lib/email-html.ts:319-323), so blocked newsletters show rows of broken-image glyphs with English alt text; no failed-image hiding. - Fix hint: use the same transparent-GIF +
display:noneswap and portcollapseBlockedImageContainersas a regex/DOM pass in the injected script; add animgerrorlistener that hides the element.
- What WEB does: swaps
-
mailto: links inside the body open the OS mail handler instead of the app composer — fixed in
06742ef—P2—missing- What WEB does:
mailto:opens the built-in composer with parsed to/subject/body (changelog 1.8.1 "Open mailto: links in the built-in composer"; iframe click handler leavesmailto:to the app's protocol handling,components/email/email-viewer.tsx:2420-2436). - What RN does:
onShouldStartLoadWithRequesthandsmailto:toLinking.openURL(src/components/EmailBodyView.tsx:715-724), so tapping an address in an email opens Gmail/Apple Mail instead of Bulwark. - Fix hint: parse
mailto:(address,subject,body,cc) andnavigation.navigate('Compose', { prefillTo, ... }); theprefillToparam already exists (src/screens/ComposeScreen.tsx:292-298).
- What WEB does:
-
data:URL navigations replace the email body — fixed in06742ef—P3—rn-only-bug- What RN does:
onShouldStartLoadWithRequestreturnstruefor anydata:URL (src/components/EmailBodyView.tsx:717-719), andsafeUrikeepshref="data:image/…"(src/lib/email-html.ts:109-113); tapping such a link navigates the WebView to the image with no way back. Only the initialabout:blankload should be allowed. - Fix hint: allow
data:only whenrequest.navigationType === 'other' && !request.isTopFrame-style initial loads, otherwise return false (optionally open images externally).
- What RN does:
-
Regex sanitiser +
script-src 'unsafe-inline': keep the data-URI/SVG allowlist in sync with WEB — fixed in06742ef—P3—partial- What WEB does: allows only raster
data:image/(png|jpe?g|gif|webp|bmp|avif|x-icon|vnd.microsoft.icon)on media tags, re-checkssrcsetcandidates individually, forbids SVG data URIs and<svg>(lib/email-sanitization.ts:24,330-388), and the iframe has no scripting at all. - What RN does:
safeUriallows anydata:image/*includingimage/svg+xml(src/lib/email-html.ts:109-113),srcsetvalues are never scheme-checked except when blocking (326-329), and the page CSP must allow'unsafe-inline'scripts for the injected bridge (236-245), so a parser-differential bypass of the regex stripper would execute script in the WebView (limited blast radius: the bridge only accepts height/swipe/zoom strings). - Fix hint: restrict
data:to the raster list, scheme-check everysrcsetcandidate, and consider parsing with a real HTML parser (e.g.htmlparser2/parse5on the JS side) instead of regexes; document the residual risk.
- What WEB does: allows only raster
-
Quoted reply text is not collapsed (#480) — fixed in
06742ef—P3—missing- What WEB does: hides the trailing quote (Gmail/Outlook/Apple/Thunderbird/Bulwark markers, attributed
<blockquote>,>-lines in plain text) behind a "•••" toggle (lib/quote-collapse.ts:143-197,219-259; changelog 1.7.8). - What RN does: nothing; long threads show every quoted original inline.
- Fix hint: port
setupQuoteCollapseinto the injected WebView script (runs on the parsed DOM, uses inline styles only) andcollapsePlainTextQuotesintowrapPlainTextEmail.
- What WEB does: hides the trailing quote (Gmail/Outlook/Apple/Thunderbird/Bulwark markers, attributed
-
Plain-text bodies are always monospace (#830) — fixed in
06742ef—P3—bugfix-parity- What WEB does: renders text/plain in the app font by default with a
plainTextFont: 'sans' | 'mono'setting (stores/settings-store.ts:329,565;components/email/email-viewer.tsx:5050-5060; changelog 1.9.0). - What RN does:
wrapPlainTextEmailhard-codesui-monospace, Menlo, …(src/lib/email-html.ts:290); no setting (src/stores/settings-store.tshas noplainTextFont). - Fix hint: add the setting (Reading settings) and switch the font-family in
wrapPlainTextEmail.
- What WEB does: renders text/plain in the app font by default with a
-
Plain-text linkifier only catches http(s) — verified, no action —
P3—verified(same as WEBplainTextToSafeHtml); no action. -
cid images larger than 2 MB and non-image cid parts silently show a 1x1 placeholder; octet-stream cid parts (#543) unverified — fixed in
06742ef—P3—partial- What WEB does: fetches every cid part as an authenticated
blob:URL regardless of size/type; the browser sniffsapplication/octet-streamimage bytes (components/email/email-viewer.tsx:1509-1575; changelog 1.8.1 #543). - What RN does: skips parts over
MAX_INLINE_IMAGE_BYTES(src/components/EmailBodyView.tsx:15,556-559) and buildsdata:${att.type || 'application/octet-stream'};base64,…(573), which Chromium/WebKit may refuse to decode as an image forapplication/octet-stream. - Fix hint: sniff magic bytes (PNG/JPEG/GIF/WebP) to pick the MIME for the data URI; for >2 MB parts encode off the JS thread (e.g.
File.base64()from expo-file-system afterdownloadInto) instead of dropping them.
- What WEB does: fetches every cid part as an authenticated
Header / meta
-
headers,messageId,inReplyTo,referencesare never fetched — fixed inccbe67c—P2—missing(root cause for the next five items)- What WEB does:
Email/getrequestsmessageId, inReplyTo, references, headers, bodyStructureand parsesAuthentication-Results,X-Spam-*,X-Spam-LLMintoemail.authenticationResults/spamScore/spamLLM(lib/jmap/client.ts:1775-1870). - What RN does:
EMAIL_FULL_PROPERTIESstops atbodyStructure, textBody, htmlBody, bodyValues, attachments, blobId, bcc, replyTo, sentAt(src/api/email.ts:11-15); theEmailtype has noheaders/messageId(src/api/types.ts:65-87). - Fix hint: add the four properties to
EMAIL_FULL_PROPERTIES, extend the type, and portparseAuthenticationResults/parseSpamScore/extractListHeaders(lib/email-headers.ts) tosrc/lib/email-headers.ts.
- What WEB does:
-
No "Show details" panel: CC/BCC/Reply-To/sent vs received/size/MIME/Message-ID/thread id — fixed in
d2ed27f—P2—missing- What WEB does: header shows To/CC/BCC (first 2 + "+N", "me" substitution), a "Show details" toggle with recipients & routing, delivery delta, identifiers/threading, message properties, mailing-list section (
components/email/email-viewer.tsx:3892-3944,4187-4542). - What RN does: one line
to <names>(src/screens/EmailThreadScreen.tsx:806-809); CC/BCC/Reply-To are fetched but never shown; date/size only (811-817). - Fix hint: add CC/BCC rows and a collapsible details block under the sender row; reuse
formatSize.
- What WEB does: header shows To/CC/BCC (first 2 + "+N", "me" substitution), a "Show details" toggle with recipients & routing, delivery delta, identifiers/threading, message properties, mailing-list section (
-
SPF/DKIM/DMARC/spam-score chips and spoof-aware identity badge — fixed in
d2ed27f—P3—missing- What WEB does: auth chips with severity-ranked SPF (HELO vs MAIL FROM, #650), DMARC policy, iprev, spam score/LLM verdict (
components/email/email-viewer.tsx:4353-4438,lib/email-headers.ts:13-115); "via " / sub-address tag badge hidden when spoofed (components/email/email-identity-badge.tsx:33-68, changelog 1.7.3). - What RN does: none.
- Fix hint: after the headers finding, render chips in the details block; port
isAuthenticationSpoofedfor the badge.
- What WEB does: auth chips with severity-ranked SPF (HELO vs MAIL FROM, #650), DMARC policy, iprev, spam score/LLM verdict (
-
List-Unsubscribe (RFC 2369) banner — fixed in
d2ed27f—P2—missing- What WEB does: parses
List-Unsubscribe(http preferred, else mailto), confirm dialog, sends the mailto unsubscribe through the account itself, remembers dismissals per Message-ID (components/email/unsubscribe-banner.tsx:64-106,components/email/email-viewer.tsx:753-773,2672-2680; changelog 1.7.7). - What RN does: none.
- Fix hint: port
parseUnsubscribeUrls/isValidUnsubscribeUrlfromlib/validation.ts; for mailto callsendEmailwith the parsed fields; open http viaexpo-web-browser.
- What WEB does: parses
-
Read receipts (MDN, RFC 8098): request banner, send, "always/ask/never",
$mdnsent— fixed ind2ed27f—P2—missing— the receipt headers were not sanitized (GHSA-w38p) and "Always send" answered the unseen neighbour pages untilcb6ddd6and5f3f584(audit S1, B7)- What WEB does: detects
Disposition-Notification-To, offers Send/Ignore (or auto-sends in "always" mode) only in received folders, builds the multipart/report withlib/mdn.ts, uploads →Email/importinto Sent →EmailSubmission/setwith explicit envelope, then sets$mdnsent(components/email/email-viewer.tsx:2686-2784,lib/jmap/client.ts:4080-4130,components/email/read-receipt-banner.tsx). - What RN does: none; no
readReceiptResponsesetting. - Fix hint: port
lib/mdn.tsverbatim (pure string builder), reuseuploadBytes(src/api/blob.ts:67) +importEmailBlob(src/api/email.ts:423) + anEmailSubmission/sethelper; add the setting.
- What WEB does: detects
-
Reply-To shown / used — fixed in
7f956d6— folded into the reply-recipients finding below. -
Sender / recipient tap actions (popover, view/add contact, copy address) — fixed in
d2ed27f—P3—missing- What WEB does: every address is a
RecipientPopover(contact lookup, extra emails/phones, copy, email, view/add contact); tapping the avatar opens the contact sidebar or, on mobile, the contact page (components/email/recipient-popover.tsx,components/email/email-viewer.tsx:1140-1170,3841-3866). - What RN does: names are static
Text(src/screens/EmailThreadScreen.tsx:794-810). - Fix hint: wrap sender/recipients in
Pressableopening a small action sheet (copy, compose to, openContactDetail/ContactFormprefilled).
- What WEB does: every address is a
-
Displayed date uses
receivedAtand device locale, ignoring the app's time-format setting — fixed ind2ed27f—P3—bugfix-parity- What WEB does:
emailDisplayDatepreferssentAt(falls back when missing or >24 h in the future, #891) and formats with the user'stimeFormat/locale (lib/email-date.ts:33-45,components/email/email-viewer.tsx:3811). - What RN does:
formatHeaderDate(email.receivedAt)withtoLocaleDateString(undefined, …)(src/screens/EmailThreadScreen.tsx:39-50,812-816), so imported/migrated mail shows the import date and 12/24 h ignores the setting. - Fix hint: port
emailDisplayDate(already insrc/lib/date-format.ts?—if not, 15 lines) and useuseSettingsStore(s => s.timeFormat)+ locale fromuseLocaleStore.
- What WEB does:
-
Tags,
$importantbadge and answered/forwarded state in the header — fixed ind2ed27f—P3—missing- What WEB does: tag badges (removable) under the subject, "Important" pill (
components/email/email-viewer.tsx:3789-3806);$answered/$forwardedset after send (components/mail/mail-app.tsx:1663-1667). - What RN does: Tag sheet exists but no badges are rendered in the pane;
$answered/$forwardedare never set after a reply/forward (no hit for$answeredinsrc/). - Fix hint: render
keywordDefsmatches fromemail.keywordsunder the subject; after a successful send inComposeScreencallsetEmailKeywords(originalId, {...keywords, $answered|$forwarded: true}, accountId). (setEmailKeywordsis gone since5041897: setkeywords/$answeredorkeywords/$forwardedthroughpatchKeywordsForEmailsor the store'ssetKeywordForEmails.)
- What WEB does: tag badges (removable) under the subject, "Important" pill (
Calendar invitation banner
-
Invitation parsing is not routed to the owning account (#847, #867) — fixed in
8206893—P2—bugfix-parity- What WEB does: parses/fetches the ICS through the message's source client and owner account, including directly viewed shared folders (
components/email/calendar-invitation-banner.tsx:373-388,412-432; changelog 1.9.0). - What RN does:
parseCalendarBlob(attachment.blobId)usesjmapClient.accountId(src/components/email/CalendarInvitationBanner.tsx:54,src/api/calendar.ts:355-360) even thoughEmailPaneknowsjmapAccountId; Stalwart answersCalendarEvent/parsewith an error for a blob in another account, so invitations in group/shared mailboxes show nothing (stateerror→ banner hidden). - Fix hint: pass
jmapAccountIdfromEmailPaneinto the banner and through toparseCalendarBlob(blobId, accountId).
- What WEB does: parses/fetches the ICS through the message's source client and owner account, including directly viewed shared folders (
-
iMIP method detection ignores the raw ICS
METHODand Content-Type params — fixed in8206893—P2—partial- What WEB does: reads
method=from the part/attachment/header Content-Type, then fetches the raw ICS and readsMETHOD:(lib/calendar-invitation.ts:319-339,components/email/calendar-invitation-banner.tsx:419-447), falling back to participant heuristics;CANCEL,REPLY,COUNTER,REFRESH,DECLINECOUNTER,PUBLISH,ADDeach get their own title/info/actions. - What RN does:
extractMethodFromRawIcsexists (src/lib/calendar-invitation.ts:103-106) but is never called; onlyinferInvitationMethodheuristics are used (CalendarInvitationBanner.tsx:62), so a cancellation whose event status is notcancelled, or a REPLY from a single attendee, is shown as a plain invitation with RSVP buttons. - Fix hint: fetch the blob text (
getDownloadUrl+secureFetch, asfetchRawEmaildoes) in parallel with parse and preferextractMethodFromRawIcs.
- What WEB does: reads
-
Banner lacks WEB's trust assessment, actor summary, existing-event / "already in calendar" state, calendar picker, "View in calendar", collapse, sequence badge, counter-proposal review —
P3—partial— deferred: trust assessment, iTIP method and calendar picker landed in8206893(calendar agent); the existing-event state followed inc0582ab, which looks the event up on the server byuid(also so Accept sends the RSVP, audit B22); "View in calendar", collapse, sequence badge and counter-proposal review remain open in the calendar area- What WEB does:
getInvitationTrustAssessment(sender vs organizer + auth results) warning, actor line ("X accepted"),queryCalendarEvents({uid})to show "already in calendar" and current RSVP, picker when >1 calendar, "View in calendar" navigation, collapsible card,sequence"updated" pill, apply counter proposal for organizers (components/email/calendar-invitation-banner.tsx:392-474,502-553,657-672,721-752,802-1140). - What RN does: title/date/location/video/organizer rows, Yes/Maybe/No, Add to first writable calendar (
src/components/email/CalendarInvitationBanner.tsx:83-207); no dedupe check before offering "Add", no picker, no trust warning, no navigation to the event. - Fix hint: port
getInvitationTrustAssessment(needs the headers finding for auth), look upuseCalendarStore.eventsbyuidto switch to "already in calendar"/current response, add a calendar picker sheet whencalendars.length > 1, and a "View in calendar" button that navigates toCalendarScreenwith the start date.
- What WEB does:
-
The
.icspart stays in the attachment list while the banner is shown — fixed ind2ed27f—P3—bugfix-parity- What WEB does: hides calendar MIME parts when the banner renders (
components/email/email-viewer.tsx:1591-1600; changelog 1.4.14). - What RN does:
renderAttachmentsfilters only inline images (src/screens/EmailThreadScreen.tsx:713-722). - Fix hint: filter
isCalendarType(att.type) || name.endsWith('.ics')whencalendarInvitationParsingEnabled && findCalendarAttachment(email).
- What WEB does: hides calendar MIME parts when the banner renders (
Attachments
-
Forwarding drops every attachment — fixed in
7f956d6—P1—bug- What WEB does: forward pre-populates the composer with the original's attachments by blobId (minus embedded cid images) (
components/email/email-composer.tsx:598-616, #543). - What RN does:
navigateCompose('forward')passes only from/to/cc/subject/plain body (src/screens/EmailThreadScreen.tsx:366-382); theCompose.replyToparam has noattachmentsfield (src/navigation/types.ts:7-19) andComposeScreennever seedsattachmentsfrom the original (src/screens/ComposeScreen.tsx:354). A forwarded invoice arrives without the PDF. - Fix hint: add
attachments?: Attachment[]to the route param, and inComposeScreeninitialiseattachmentswith{blobId,name,type,size}entries (blobs are account-scoped so no re-upload is needed; for shared-folder messages the blob belongs to the owner account — either re-upload viagetDownloadUrl(..., ownerAccountId)+uploadBytes, or block forward there).
- What WEB does: forward pre-populates the composer with the original's attachments by blobId (minus embedded cid images) (
-
message/rfc822 attachments: no inline unwrap, no
.emlpreview — fixed ind2ed27f—P2—missing- What WEB does: when the outer body is empty (Outlook "forward as attachment") it parses the embedded message with postal-mime and renders its body + attachments, keeping the
.emlchip;.emlattachments preview like an email (components/email/email-viewer.tsx:1433-1507,1625-1636; changelog 1.7.3, 1.8.1). - What RN does: the message renders "(empty message)" and the
.emlchip can only be shared. - Fix hint: add
postal-mime(pure JS, works in RN with aTextDecoderpolyfill) and mirror the unwrap effect; render the parsed HTML throughEmailBodyView.
- What WEB does: when the outer body is empty (Outlook "forward as attachment") it parses the embedded message with postal-mime and renders its body + attachments, keeping the
-
TNEF
winmail.datis not decoded — fixed ind2ed27f—P3—missing- What WEB does: parses winmail.dat (body + embedded attachments) and hides the container (
lib/tnef.ts,components/email/email-viewer.tsx:1344-1431). - What RN does: shows
winmail.datas an opaque attachment; the real files inside are unreachable. - Fix hint:
lib/tnef.tsis pureUint8Arraycode; copy it and feedjmapClient.fetchBlobArrayBuffer.
- What WEB does: parses winmail.dat (body + embedded attachments) and hides the container (
-
Attachment chip list: no MIME-based fallback names, MDN/DSN report parts and non-inline cid images mishandled — fixed in
d2ed27f—P3—partial— body-embeddedcid:parts typed octet-stream are hidden from the chips since1d1d6a5- What WEB does: unnamed parts get
Document.pdf/Email.eml/Attachment.<sub>(components/email/email-viewer.tsx:191-225);message/disposition-notificationandmessage/delivery-statusparts are hidden (1604-1606); inline hiding requiresdisposition === 'inline'(1601-1603). - What RN does: unnamed →
'attachment'(src/screens/EmailThreadScreen.tsx:745); report parts are listed; anycidimage is hidden regardless of disposition (719-721), so an image attached with a Content-ID butdisposition: attachment(common from some clients) disappears from the list even when the body does not reference it. - Fix hint: port
getAttachmentDisplayName, add the two report-type filters, and checkdisposition === 'inline'(or that the body actually references the cid viaextractCidRefs).
- What WEB does: unnamed parts get
-
No image thumbnails on chips, no "Download all" zip, no per-chip Preview vs Download choice — fixed in
d2ed27f—P3—missing- What WEB does: image chips show the actual image (≤10 MB), a "Download all" bundles into a zip named by template, hover reveals separate Download/Preview buttons (
components/email/email-viewer.tsx:2024-2102,2104-2161,3949-4085; changelog 1.6.0, 1.7.5). - What RN does: single tap follows the global
mailAttachmentAction;jszipis already a dependency (package.json:36) but unused for mail. - Fix hint: long-press sheet with "Open / Save / Share"; a "Download all" entry building the zip with
jszipintoPaths.cachethen sharing.
- What WEB does: image chips show the actual image (≤10 MB), a "Download all" bundles into a zip named by template, hover reveals separate Download/Preview buttons (
-
In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in
d2ed27f—P3—partial- What WEB does:
FilePreviewModalfor images/PDF (bytes handed to pdf.js, #871)/text/markdown/audio/video/eml, script-bearing types forced to download (components/mail/mail-app.tsx:2887-2935,lib/file-preview.ts:29-92). - What RN does: Android hands the file to an external viewer via
ACTION_VIEW, iOS opens the share sheet (src/lib/email-export.ts:64-79,115-141); nothing is previewed inside the app. - Fix hint: for images/PDF/text open a modal
WebViewon the cachedfile://(or useexpo-file-system'sFile.uriwith a QuickLook bridge on iOS); keep the external-viewer path as fallback.
- What WEB does:
-
Post-export action (archive/trash after "Export .eml") missing — fixed in
d2ed27f—P3—missing- What WEB does:
postExportActionsetting runs archive/trash after export (components/email/email-viewer.tsx:2559-2571). - What RN does: export only (
src/screens/EmailThreadScreen.tsx:632-640). - Fix hint: add the setting and call
onArchive/onDeleteaftershareEmailEmlresolves.
- What WEB does:
Actions from the viewer
-
In-Reply-To/Referencesare written with the JMAP email id (#234 bugfix-parity) — fixed in7f956d6—P1—bug- What WEB does: passes the original's RFC
messageIdandreferences, stripped of brackets, as JMAPinReplyTo/referencesarrays (lib/jmap/client.ts:3124-3194; changelog 1.5.4 #234). - What RN does:
navigateComposesetsinReplyTo: email.id(src/screens/EmailThreadScreen.tsx:379), andsendEmailwrites it verbatim intoheader:In-Reply-To:asText/header:References:asText(src/api/email.ts:823-826). The outgoing header is a bare Stalwart object id, not a msg-id, so recipients' clients cannot thread the reply andReferencesis never accumulated.messageId/referencesare not even fetched (see headers finding). - Fix hint: fetch
messageId/references, passinReplyTo: email.messageId?.[0]andreferences: [...(email.references ?? []), email.messageId[0]], and send them as JMAPinReplyTo/referencesarrays (or<…>-wrappedheader:…:asMessageIds).
- What WEB does: passes the original's RFC
-
Reply addressing: Reply-To ignored, own addresses kept on reply-all, self-sent thread replies (#703), external Reply-To on self-sent (1.9.0) — fixed in
7f956d6—P1—bugfix-parity- What WEB does:
buildReplyRecipients— reply goes toReply-Toif present elseFrom; reply-all adds original To/CC minus the user's own identities (exact and+tag-stripped); replying to your own message continues to its original recipients; an external Reply-To on a self-sent message still wins (lib/reply-recipients.ts:74-111,components/mail/mail-app.tsx:920,2940-2944). - What RN does:
initialTo= From (+ all original To on reply-all, including the user's own address),initialCc= all CC;email.replyTois fetched but never passed (src/screens/ComposeScreen.tsx:299-319,src/screens/EmailThreadScreen.tsx:366-382). Replying to a newsletter withReply-To: support@…mails the no-reply sender; reply-all sends the user a copy; replying to your own sent message addresses yourself. - Fix hint: copy
lib/reply-recipients.ts(pure), passreplyToAddresses: email.replyToin the route param and the identities' emails asownEmails.
- What WEB does:
-
Reply/forward quote is plain text only — HTML formatting and inline images lost (#163, #543) — fixed in
7f956d6—P2—bugfix-parity- What WEB does: quotes the sanitized HTML body (with cid images re-attached inline) inside an editable quote island, localized "On … wrote:" header with
Name <email>(components/email/email-composer.tsx:462-500,lib/quote-header.ts:54-91; changelog 1.7.1, 1.7.3, 1.8.1). - What RN does:
buildInitialHtmlsupportshtmlBody(src/lib/compose-html.ts:79-83) but the viewer only passesbody: plainTextBody(email)(src/screens/EmailThreadScreen.tsx:377), and the header uses display name only (compose-html.ts:31-35) rather than WEB'sName <email>(#482). - Fix hint: pass
htmlBody(sanitized viastripDangerousTags, cid refs rewritten to fetched data URIs or re-attached with the samecid) and switchsenderNameto theName <email>form.
- What WEB does: quotes the sanitized HTML body (with cid images re-attached inline) inside an editable quote island, localized "On … wrote:" header with
-
Forward as attachment (.eml) missing — fixed in
d2ed27f—P3—missing- What WEB does: More menu "Forward as attachment" builds a
message/rfc822attachment referencing the original blobId with a{date}-{subject}.emlname (lib/forward-as-attachment.ts:38-58,components/mail/mail-app.tsx:2022-2060; changelog 1.8.1). - What RN does: none.
- Fix hint: add a More-sheet entry that opens
Composewithattachments: [{blobId: email.blobId, name, type: 'message/rfc822', size: email.size}](depends on the forward-attachments fix).
- What WEB does: More menu "Forward as attachment" builds a
-
Reply identity selection: exact match only, no
+tagor catch-all domain override (#246) — fixed in5b72c4d—P3—partial- What WEB does:
resolveReplyFrom— exact, then+tag-stripped, then same-domain catch-all with a From override (lib/reply-identity.ts:197-256). - What RN does:
identities.find(i => i.email === candidate.email)(src/screens/ComposeScreen.tsx:431-437). - Fix hint: port
resolveReplyFrom; the override needs a From override insendEmail.
- What WEB does:
-
Quick reply box under the message — fixed in
d2ed27f—P3—missing- What WEB does: inline textarea with Send / "More options" (opens composer prefilled), Ctrl+Enter (
components/email/email-viewer.tsx:5066-5154). - What RN does: none.
- Fix hint: optional; a bottom input that calls
sendEmailwithbuildReplyRecipientsoutput and the plain-text quote.
- What WEB does: inline textarea with Send / "More options" (opens composer prefilled), Ctrl+Enter (
-
View source: no copy-to-clipboard; whole raw message loaded into a
<Text>— fixed ind2ed27f—P3—partial- What WEB does: synthetic source from the JMAP object with a Copy button (
components/email/email-viewer.tsx:5159-5208,lib/email-source.ts). RN's raw RFC 822 view is actually more faithful. - What RN does: fetches the full blob as text and renders it in one selectable
Text(src/screens/EmailSourceScreen.tsx:24-35,68-70); a 30 MB message will stall the JS thread. - Fix hint: add
Clipboard.setStringAsync; cap display at e.g. 1 MB with a "Share full source" fallback.
- What WEB does: synthetic source from the JMAP object with a Copy button (
-
Add sender to contacts / edit contact from the viewer — fixed in
d2ed27f—P3—missing- What WEB does: contact sidebar with "Add to contacts"/"Edit" (
components/email/email-viewer.tsx:552-568,5336-5368; changelog 1.7.4). - What RN does: none (see popover finding).
- Fix hint: sheet entry navigating to
ContactFormwith prefilled name/email.
- What WEB does: contact sidebar with "Add to contacts"/"Edit" (
Trusted senders / settings
-
"Trust sender" writes to the Trusted Senders address book even when the setting is off — fixed in
06742ef—P3—rn-only-bug- What WEB does: writes to the book only when
trustedSendersAddressBookis on, else to the local list (components/email/email-viewer.tsx:4646-4655,components/trusted-senders-modal.tsx:136-140). - What RN does: always does both (
src/components/EmailBodyView.tsx:644-653), andaddToTrustedSendersBookcreates the book on first add (src/stores/contacts-store.ts:308-321), so a user who left the setting off (defaultfalse,src/stores/settings-store.ts:239) still gets a server-side "Trusted Senders" address book; the settings modal then lists only the local entries (src/components/settings/ContentSendersSettings.tsx:19-21,123-144), so book-trusted senders are invisible/unremovable there. - Fix hint: gate the book write on the setting (or, matching WEB 1.9.0, default the setting to on for contacts-capable accounts) and show
trustedSenderEmailsin the settings modal with remove support.
- What WEB does: writes to the book only when
-
S/MIME settings screen is a dead stub — fixed in
3d0d440—P3—rn-only-bug- What WEB does: built-in S/MIME was removed from core in 1.7.6 and lives in a privileged plugin; the viewer has no S/MIME state (
components/email/email-viewer.tsx:923-937). - What RN does:
SmimeSettingsrenders empty mock arrays and buttons with no handlers (src/components/settings/SmimeSettings.tsx:32-33,118-121,163-166); there is no verification/decryption in the viewer. - Fix hint: hide the screen (or show a "not available in the mobile app" note) until a crypto path exists; treat viewer-side S/MIME as N/A.
- What WEB does: built-in S/MIME was removed from core in 1.7.6 and lives in a privileged plugin; the viewer has no S/MIME state (
Verified at parity (brief list, so the fixer knows what NOT to redo)
- Body isolation: sandboxed WebView with
default-src 'none'CSP,originWhitelistabout:blank, links opened externally, no cookies/storage/file access (src/components/EmailBodyView.tsx:675-724,src/lib/email-html.ts:236-256) — matches WEB's srcDoc iframe + CSP approach (1.6.7). hasMeaningfulHtmlBodytext-alternative preference (src/lib/email-html.ts:353-362) — same regex as WEBlib/signature-utils.ts(only the same-partId guard is missing, see P1 finding).- Dark mode: invert+hue-rotate with media re-invert, native-dark-mode detection, emoji re-invert pass, "Always view emails in light mode" setting (
src/lib/email-html.ts:167-185,197-199,src/components/EmailBodyView.tsx:64-157,499-502) — only the CSS revision differs (see finding). - Plain-text path: escaped + linkified, native dark colours without inversion (
src/lib/email-html.ts:120-126,263-311). - External content policy ask/block/allow, per-message "Load external content", "Trust sender" persisting locally and to the address book, trusted list from the address book and contacts (
src/components/EmailBodyView.tsx:480-535,643-672). - cid inline images fetched with auth and injected as data URIs, transparent placeholder otherwise (
src/components/EmailBodyView.tsx:546-584,src/lib/email-html.ts:213-218). - Shrink-to-fit of fixed-width emails + in-page pinch zoom/pan with pager/scroll locking (
src/components/EmailBodyView.tsx:159-460) — RN-only, better than WEB's horizontal scroll (#409). - Mark read on open with delay setting (apart from the
-1bug), optimistic star/unread/tag toggles, delete/archive/spam/not-spam/move scoped to the message's account,no trash folderalert (src/screens/EmailThreadScreen.tsx:221-364). The account scoping was wrong for messages opened from the Unified Inbox, a notification or a deep link until5c7301b(audit B3); star, tag and unread are queued offline sinceddff2f7. - Prev/next navigation with prefetched neighbour panes and swipe (
src/screens/EmailThreadScreen.tsx:122-181,535-572) — RN-only pager. - Skeleton while loading (
src/screens/EmailThreadScreen.tsx:845-886). - Attachments: chips with size, show-all toggle,
hideInlineImageAttachmentssetting,attachmentPositionsetting,mailAttachmentActionpreview/download, templated filenames + transforms identical to WEB (src/lib/download-filename.tsdiff vslib/download-filename.tsis whitespace/quotes only, minusDEFAULT_BUNDLE_TEMPLATE/BUNDLE_TOKENS), Downloads settings screen with live preview (src/components/settings/DownloadsSettings.tsx). - Attachment/blob/source/.eml fetches routed by owner account for shared/group messages (
src/screens/EmailThreadScreen.tsx:87-96,198-215,src/api/blob.ts:119-135,src/lib/email-export.ts) — #847/#867 parity except the calendar banner. - Client-cert-aware downloads via
secureFetch, streaming download viaFile.downloadFileAsync(src/lib/email-export.ts:85-113). - View source (raw RFC 822, shareable) and Export .eml with the email filename template (
src/screens/EmailSourceScreen.tsx,src/lib/email-export.ts:173-206). - Calendar invitation: detection by MIME/extension, parse via
CalendarEvent/parse, RSVP via import-then-rsvpEventwithreplyTo/organizerCalendarAddress, cancelled notice, "no writable calendar" warning,calendarInvitationParsingEnabledsetting (src/components/email/CalendarInvitationBanner.tsx,src/lib/calendar-invitation.ts). - Tag sheet from the viewer with keyword definitions (
src/screens/EmailThreadScreen.tsx:1033-1100).
N/A on mobile
- Print (WEB
handlePrint,components/email/email-viewer.tsx:2621-2670) — could be done withexpo-printlater, but not a parity requirement. - Copy permalink to a message (#733) — RN opens incoming message links since
5802cae(src/navigation/linking.ts) but has no "copy link" action. - Fullscreen reading toggle, Pro split panes, toolbar overflow measurement, plugin slots/hooks (
email-banner,email-detail-sidebar,onRenderEmailBody,onBeforeExternalLink,ui.rerenderEmail), keyboard shortcuts, drag attachments out to the file system (#267), drag emails out as.eml, import.emlfrom the viewer menu (RN imports from the list screen instead). - Iframe-specific fixes: "Render the email body on DOM parse instead of iframe load" (#635), iframe flash/flicker fixes (1.5.4, 1.6.7),
blob:in CSPobject-src/frame-srcfor PDF previews (#253), pdf.js bytes vsblob:fetch (#871), RSC 307 loop (#919), iOS Safari viewport zoom on input focus (#838). - Demo-mode welcome pane, tour, contact sidebar panel layout (desktop only; the actions themselves are listed above).
- S/MIME plugin UI (WEB core has none since 1.7.6); listed above only because RN ships a misleading stub.