From 06742ef7d50425eb50ab09b8e75c6005631f8d55 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:15:22 +0200 Subject: [PATCH] fix: render HTML-only mail as HTML, keep stylesheets and block every external-content vector in the reader (#46, #49) --- src/components/EmailBodyView.tsx | 319 +++++++++++------- src/components/settings/DownloadsSettings.tsx | 20 +- src/components/settings/ReadingSettings.tsx | 41 +++ src/lib/__tests__/email-body.test.ts | 53 +++ src/lib/__tests__/email-export.test.ts | 49 ++- src/lib/__tests__/email-html.test.ts | 219 ++++++++++++ src/lib/__tests__/quote-collapse.test.ts | 42 +++ src/lib/__tests__/unsubscribe.test.ts | 48 +++ src/lib/email-body.ts | 81 +++++ src/lib/email-export.ts | 284 +++++++++++++--- src/lib/email-html.ts | Bin 15066 -> 30151 bytes src/lib/quote-collapse.ts | 230 +++++++++++++ src/lib/unsubscribe.ts | 119 +++++++ src/stores/settings-store.ts | 46 +++ 14 files changed, 1390 insertions(+), 161 deletions(-) create mode 100644 src/lib/__tests__/email-body.test.ts create mode 100644 src/lib/__tests__/email-html.test.ts create mode 100644 src/lib/__tests__/quote-collapse.test.ts create mode 100644 src/lib/__tests__/unsubscribe.test.ts create mode 100644 src/lib/email-body.ts create mode 100644 src/lib/quote-collapse.ts create mode 100644 src/lib/unsubscribe.ts diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 93d27e29..32488f08 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -1,28 +1,25 @@ import React from 'react'; import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-native'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native'; import type { Email } from '../api/types'; -import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody, hasNativeDarkMode } from '../lib/email-html'; -import { jmapClient } from '../api/jmap-client'; +import { + prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, +} from '../lib/email-html'; +import { pickEmailBody, selectRenderableHtml } from '../lib/email-body'; +import { buildQuoteCollapseScript, collapsePlainTextQuotes } from '../lib/quote-collapse'; +import { parseMailtoUrl } from '../lib/unsubscribe'; +import { fetchInlineImageDataUri } from '../lib/email-export'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; +import { useLocaleStore } from '../stores/locale-store'; import { spacing, typography, type ThemePalette } from '../theme/tokens'; import { useColors, useResolvedTheme } from '../theme/colors'; +import type { RootStackParamList } from '../navigation/types'; -// Largest inline image we'll pull inline as a data: URI. Anything bigger gets -// skipped so we don't freeze the JS thread base64-encoding megabytes. -const MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024; - -function arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - let binary = ''; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) { - binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk))); - } - return global.btoa ? global.btoa(binary) : btoa(binary); -} +type Nav = NativeStackNavigationProp; interface EmailBodyViewProps { email: Email; @@ -38,22 +35,14 @@ interface EmailBodyViewProps { // the surrounding vertical scroll / horizontal pager so a zoom gesture can't // scroll the pane away or switch mails. onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void; -} - -function extractHtmlBody(email: Email): string | null { - for (const part of email.htmlBody ?? []) { - const v = part.partId ? email.bodyValues?.[part.partId]?.value : undefined; - if (v) return v; - } - return null; -} - -function extractTextBody(email: Email): string | null { - for (const part of email.textBody ?? []) { - const v = part.partId ? email.bodyValues?.[part.partId]?.value : undefined; - if (v) return v; - } - return null; + /** Per-message override of the light/dark rendering (More sheet toggle). */ + themeOverride?: 'light' | 'dark' | null; + /** + * Replace the message's own body - used when the outer message is an empty + * envelope around a message/rfc822 attachment or a TNEF winmail.dat and the + * real content was extracted client-side. + */ + bodyOverride?: { html?: string | null; text?: string | null } | null; } // Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The @@ -80,10 +69,13 @@ const DARK_REINVERT_SCRIPT = ` if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return; var computed = win.getComputedStyle(el); if (computed.backgroundImage && computed.backgroundImage !== 'none') { - // Only re-invert if this container doesn't have media children. - if (!el.querySelector('img, video, svg, canvas, object, embed')) { - el.style.filter = 'invert(1) hue-rotate(180deg)'; - } + // Re-invert so the background image returns to its original + // appearance. Matches the CSS background-image rules: re-invert even + // when media is nested, then cancel the per-image re-invert on that + // media so it isn't left double-inverted. + el.style.filter = 'invert(1) hue-rotate(180deg)'; + el.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]') + .forEach(function (m) { m.style.filter = 'none'; }); } }); @@ -156,6 +148,57 @@ const DARK_REINVERT_SCRIPT = ` })(); `; +// Image fix-ups, mirroring the webmail's post-render passes: +// - collapse empty /
wrappers left behind by blocked images so a +// blocked newsletter doesn't show rows of empty cells; +// - hide images that fail to load (dead/unreachable URLs) rather than leave +// the engine's broken-image glyph and alt text as stray labels. +const IMG_FIXUP_SCRIPT = ` +(function () { + if (document.__rnImgFixupDone) return; + var doc = document; + if (!doc.body) return; + document.__rnImgFixupDone = true; + + doc.querySelectorAll('img[data-blocked-src]').forEach(function (img) { + var el = img.parentElement; + while (el && el !== doc.body) { + if (el.tagName === 'TD' || el.tagName === 'TH' || + (el.tagName === 'DIV' && el.parentElement && el.parentElement.tagName === 'TD')) { + var hasText = (el.textContent || '').replace(/[\\s\\u00A0]+/g, '').trim(); + var hasMedia = el.querySelector('img:not([data-blocked-src]), video, canvas'); + var hasLinks = el.querySelector('a[href]'); + if (!hasText && !hasMedia && !hasLinks) { + el.setAttribute('data-blocked-collapsed-style', el.style.cssText); + el.style.display = 'none'; + el.style.height = '0'; + el.style.padding = '0'; + el.style.overflow = 'hidden'; + } + break; + } + if (el.tagName === 'TABLE' || el.tagName === 'TR') break; + el = el.parentElement; + } + }); + + function hideIfBroken(img) { + if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) { + img.style.display = 'none'; + } + } + doc.querySelectorAll('img').forEach(function (img) { + if (img.hasAttribute('data-blocked-src')) return; + if (img.complete) { + hideIfBroken(img); + } else { + img.addEventListener('error', function () { img.style.display = 'none'; }, { once: true }); + img.addEventListener('load', function () { hideIfBroken(img); }, { once: true }); + } + }); +})(); +`; + const HEIGHT_REPORTER = ` (function () { // One-time: wrap body contents in a block-level div we can measure reliably. @@ -460,16 +503,19 @@ const PINCH_ZOOM = ` `; export default function EmailBodyView({ - email, senderEmail, jmapAccountId, onSwipe, onZoomChange, + email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, }: EmailBodyViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const navigation = useNavigation