mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
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.
This commit is contained in:
@@ -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(
|
||||
`<em style="color:#71717a">${t('email_viewer.no_body_content', '(No body content available)')}</em>`,
|
||||
{ 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;
|
||||
|
||||
@@ -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> = {}): BodyDocumentInput {
|
||||
return {
|
||||
key: '|e1',
|
||||
rawHtml: '<p>Hello <img src="https://tracker.example/p.gif"></p>',
|
||||
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: '<p>Other</p>' })).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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
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(`<em style="color:#71717a">${input.emptyLabel}</em>`, { 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<string, { input: BodyDocumentInput; doc: BodyDocument }>();
|
||||
|
||||
function sameCidMap(a: Record<string, string>, b: Record<string, string>): 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();
|
||||
}
|
||||
Reference in New Issue
Block a user