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; audio/video, HTML, Markdown, SVG, Android PDFs and ZIPs since 0.1.80-0.1.82 —
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.