From 6ebadf738fe77bab54c0576a4fb7e9560379de2e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 24 Sep 2026 19:47:37 +0200
Subject: [PATCH] fix: sanitise a message body once instead of on every mount
The body view reset its inline-image map to a new empty object right
after mounting, which rebuilt the sandboxed document, so every body was
sanitised twice per mount. A body that mounted again (the page beside
the open one becoming the open one, a card closed and reopened, the
message opened a second time) was sanitised from scratch as well.
The document is now built by `lib/email-body-document`, which keeps the
last ten per message, body and rendering settings and hands back the
same one when they match, and the mount no longer replaces the empty
map.
---
src/components/EmailBodyView.tsx | 56 ++++------
src/lib/__tests__/email-body-document.test.ts | 65 +++++++++++
src/lib/email-body-document.ts | 104 ++++++++++++++++++
3 files changed, 193 insertions(+), 32 deletions(-)
create mode 100644 src/lib/__tests__/email-body-document.test.ts
create mode 100644 src/lib/email-body-document.ts
diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx
index 65a1e17b..add6ede5 100644
--- a/src/components/EmailBodyView.tsx
+++ b/src/components/EmailBodyView.tsx
@@ -5,11 +5,10 @@ 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 {
- prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs,
-} from '../lib/email-html';
+import { extractCidRefs } from '../lib/email-html';
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
-import { buildQuoteCollapseScript, collapsePlainTextQuotes } from '../lib/quote-collapse';
+import { buildQuoteCollapseScript } from '../lib/quote-collapse';
+import { bodyDocument } from '../lib/email-body-document';
import { parseMailtoUrl } from '../lib/unsubscribe';
import { fetchInlineImageDataUri } from '../lib/email-export';
import { isSenderContentTrusted } from '../lib/trusted-senders';
@@ -583,7 +582,9 @@ export default function EmailBodyView({
React.useEffect(() => {
setHeight(120);
- setCidMap({});
+ // Keep the empty map it started with: a new object would rebuild the
+ // document (sanitise the body) a second time on every mount.
+ setCidMap((prev) => (Object.keys(prev).length === 0 ? prev : {}));
settledRef.current = false;
}, [email.id]);
@@ -628,33 +629,24 @@ export default function EmailBodyView({
hide: t('email_viewer.hide_quoted_text', 'Hide quoted text'),
}), [t]);
- const prepared = React.useMemo(() => {
- if (rawHtml) {
- const res = prepareEmailHtml(rawHtml, {
- blockRemoteImages: shouldBlock,
- cidMap,
- isDark: renderAsDark,
- messageSpacing,
- });
- return { ...res, isHtml: true };
- }
- const fallbackText = text ?? email.preview ?? '';
- if (!fallbackText) {
- const res = prepareEmailHtml(
- `${t('email_viewer.no_body_content', '(No body content available)')}`,
- { isDark: renderAsDark },
- );
- return { ...res, isHtml: true };
- }
- const safe = collapsePlainTextQuotes(plainTextToSafeHtml(fallbackText), quoteLabels);
- return {
- html: wrapPlainTextEmail(safe, { isDark: renderAsDark, font: plainTextFont }),
- applyInversion: false,
- hasNativeDark: false,
- blockedExternal: false,
- isHtml: false,
- };
- }, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark, messageSpacing, plainTextFont, quoteLabels, t]);
+ // Sanitised and wrapped once per message, body and settings, and reused
+ // when the same body mounts again (lib/email-body-document).
+ const emptyLabel = t('email_viewer.no_body_content', '(No body content available)');
+ const prepared = React.useMemo(() => bodyDocument({
+ key: `${jmapAccountId ?? ''}|${email.id}`,
+ rawHtml,
+ text: text ?? email.preview ?? '',
+ emptyLabel,
+ blockRemoteImages: shouldBlock,
+ cidMap,
+ isDark: renderAsDark,
+ messageSpacing,
+ plainTextFont,
+ quoteLabels,
+ }), [
+ jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
+ messageSpacing, plainTextFont, quoteLabels,
+ ]);
const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]);
const showBanner = shouldBlock && prepared.blockedExternal;
diff --git a/src/lib/__tests__/email-body-document.test.ts b/src/lib/__tests__/email-body-document.test.ts
new file mode 100644
index 00000000..b685819e
--- /dev/null
+++ b/src/lib/__tests__/email-body-document.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { bodyDocument, buildBodyDocument, clearBodyDocuments, type BodyDocumentInput } from '../email-body-document';
+
+function input(over: Partial = {}): BodyDocumentInput {
+ return {
+ key: '|e1',
+ rawHtml: 'Hello 
',
+ text: null,
+ emptyLabel: '(No body content available)',
+ blockRemoteImages: true,
+ cidMap: {},
+ isDark: false,
+ messageSpacing: 'auto',
+ plainTextFont: 'sans',
+ quoteLabels: { show: 'Show quoted text', hide: 'Hide quoted text' },
+ ...over,
+ };
+}
+
+beforeEach(() => {
+ clearBodyDocuments();
+});
+
+describe('bodyDocument', () => {
+ it('builds a mounted-again body only once', () => {
+ const first = bodyDocument(input());
+ // A remount passes equal values in new objects (a fresh empty cid map).
+ const again = bodyDocument(input({ cidMap: {}, quoteLabels: { show: 'Show quoted text', hide: 'Hide quoted text' } }));
+
+ expect(again).toBe(first);
+ expect(first.isHtml).toBe(true);
+ expect(first.blockedExternal).toBe(true);
+ });
+
+ it('matches what building it directly gives', () => {
+ expect(bodyDocument(input())).toEqual(buildBodyDocument(input()));
+ const text = input({ rawHtml: null, text: 'Hi\n\n> quoted' });
+ expect(bodyDocument(text)).toEqual(buildBodyDocument(text));
+ expect(bodyDocument(text).isHtml).toBe(false);
+ const empty = input({ rawHtml: null, text: '' });
+ expect(bodyDocument(empty).html).toContain('(No body content available)');
+ });
+
+ it('rebuilds when a setting or the source changes', () => {
+ const first = bodyDocument(input());
+
+ expect(bodyDocument(input({ isDark: true }))).not.toBe(first);
+ expect(bodyDocument(input({ blockRemoteImages: false }))).not.toBe(first);
+ expect(bodyDocument(input({ rawHtml: 'Other
' })).html).toContain('Other');
+ expect(bodyDocument(input({ cidMap: { a: 'data:image/png;base64,AA' } }))).not.toBe(first);
+ });
+
+ it('keeps messages apart by key', () => {
+ const a = bodyDocument(input({ key: '|e1' }));
+ const b = bodyDocument(input({ key: 'group|e1' }));
+ expect(b).not.toBe(a);
+ expect(bodyDocument(input({ key: '|e1' }))).toBe(a);
+ });
+
+ it('forgets the least recently used documents', () => {
+ const first = bodyDocument(input({ key: '|m0' }));
+ for (let i = 1; i <= 10; i++) bodyDocument(input({ key: `|m${i}` }));
+ expect(bodyDocument(input({ key: '|m0' }))).not.toBe(first);
+ });
+});
diff --git a/src/lib/email-body-document.ts b/src/lib/email-body-document.ts
new file mode 100644
index 00000000..ddab3ba8
--- /dev/null
+++ b/src/lib/email-body-document.ts
@@ -0,0 +1,104 @@
+// The sandboxed document a message body is shown in, built once per message
+// and rendering settings. Sanitising a large newsletter is a noticeable chunk
+// of JS time, and the viewer mounts the same body again and again: the page
+// beside the open one becomes the open one, a card is collapsed and opened,
+// the message is opened a second time. Those reuse the document built before.
+
+import {
+ prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml,
+ type MessageSpacing, type PlainTextFont,
+} from './email-html';
+import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
+
+export interface BodyDocumentInput {
+ /** The message (account and id); the sources below are compared as well. */
+ key: string;
+ /** The HTML part to render, if any. */
+ rawHtml: string | null;
+ /** The plain text to render when there is no HTML part. */
+ text: string | null;
+ /** Shown when there is neither. */
+ emptyLabel: string;
+ blockRemoteImages: boolean;
+ cidMap: Record;
+ isDark: boolean;
+ messageSpacing: MessageSpacing;
+ plainTextFont: PlainTextFont;
+ quoteLabels: QuoteCollapseLabels;
+}
+
+export interface BodyDocument {
+ html: string;
+ /** Inversion CSS is in effect; the DOM re-invert pass should run. */
+ applyInversion: boolean;
+ hasNativeDark: boolean;
+ /** At least one external resource was neutralised. */
+ blockedExternal: boolean;
+ isHtml: boolean;
+}
+
+/** Build the document (no caching). */
+export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
+ const { rawHtml, text, isDark } = input;
+ if (rawHtml) {
+ const res = prepareEmailHtml(rawHtml, {
+ blockRemoteImages: input.blockRemoteImages,
+ cidMap: input.cidMap,
+ isDark,
+ messageSpacing: input.messageSpacing,
+ });
+ return { ...res, isHtml: true };
+ }
+ if (!text) {
+ const res = prepareEmailHtml(`${input.emptyLabel}`, { isDark });
+ return { ...res, isHtml: true };
+ }
+ const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels);
+ return {
+ html: wrapPlainTextEmail(safe, { isDark, font: input.plainTextFont }),
+ applyInversion: false,
+ hasNativeDark: false,
+ blockedExternal: false,
+ isHtml: false,
+ };
+}
+
+const MAX_DOCUMENTS = 10;
+const documents = new Map();
+
+function sameCidMap(a: Record, b: Record): boolean {
+ if (a === b) return true;
+ const ak = Object.keys(a);
+ return ak.length === Object.keys(b).length && ak.every((k) => a[k] === b[k]);
+}
+
+function sameSources(a: BodyDocumentInput, b: BodyDocumentInput): boolean {
+ return a.rawHtml === b.rawHtml && a.text === b.text && sameCidMap(a.cidMap, b.cidMap);
+}
+
+/**
+ * The document for a message body, built at most once per message, body and
+ * settings (see the module comment). The same input returns the same object.
+ */
+export function bodyDocument(input: BodyDocumentInput): BodyDocument {
+ const key = JSON.stringify([
+ input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont,
+ input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel,
+ ]);
+ const hit = documents.get(key);
+ if (hit && sameSources(hit.input, input)) {
+ documents.delete(key);
+ documents.set(key, hit);
+ return hit.doc;
+ }
+ const doc = buildBodyDocument(input);
+ documents.delete(key);
+ documents.set(key, { input, doc });
+ while (documents.size > MAX_DOCUMENTS) documents.delete(documents.keys().next().value as string);
+ return doc;
+}
+
+/** Forget every document built (tests). */
+export function clearBodyDocuments(): void {
+ documents.clear();
+}