Skip to content

Commit d5017a2

Browse files
hildebrandttkrathlinus
authored andcommitted
feat(email): open external links in a new tab (safely)
External web links (http/https) in rendered email bodies open in a new browser tab with target="_blank" rel="noopener noreferrer". mailto:, tel:, and in-page #anchors keep their default behavior instead of spawning a blank tab. The plaintext render path is already handled on main by #594 (ADD_URI_SAFE_ATTR in PLAIN_TEXT_RENDERED_CONFIG), so this no longer adds its own hook there — the plaintext linkifier only ever emits http(s) anchors, so the config's declarative exemption is sufficient. This change covers the paths #594 did not: - iframe HTML render: both anchor passes (the DOMPurify hook and the post-render DOM walk in email-viewer.tsx) set target=_blank on EVERY <a>, including mailto:/tel:. Now scoped to http(s) via the shared applyNewTabToAnchor() helper (http/https -> target+rel; mailto/tel/#/other -> strip target/rel). - sanitizeI18nHtml: the same DOMPurify strip dropped target/rel from translated links (e.g. the docs link in settings.security.not_available, target="_blank" in 19/22 locales). Keep the author's target and harden rel="noopener noreferrer". Tests: unit coverage for isHttpLinkHref / applyNewTabToAnchor / sanitizeI18nHtml plus an integration suite over the real plaintext and HTML/iframe render pipelines. The plaintext-hook-specific cases are dropped as redundant with #594.
1 parent f51ec50 commit d5017a2

4 files changed

Lines changed: 234 additions & 11 deletions

File tree

components/email/email-viewer.tsx

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import DOMPurify from "dompurify";
55
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
66
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
77
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
8-
import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
8+
import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
99
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
1010
import { withBasePath } from "@/lib/browser-navigation";
1111
import { Button } from "@/components/ui/button";
@@ -1662,10 +1662,8 @@ export function EmailViewer({
16621662
}
16631663
}
16641664

1665-
if (node.tagName === 'A') {
1666-
node.setAttribute('target', '_blank');
1667-
node.setAttribute('rel', 'noopener noreferrer');
1668-
}
1665+
// http(s) links open in a new tab; other schemes keep their default.
1666+
applyNewTabToAnchor(node);
16691667

16701668
// No dark mode color transforms - emails render true-to-life in iframe
16711669
});
@@ -2280,11 +2278,9 @@ export function EmailViewer({
22802278
}
22812279
});
22822280

2283-
// Make links open in new tab
2284-
doc.querySelectorAll('a').forEach(a => {
2285-
a.setAttribute('target', '_blank');
2286-
a.setAttribute('rel', 'noopener noreferrer');
2287-
});
2281+
// Second pass over the rendered iframe DOM (the hook above only sees
2282+
// DOMPurify's output); http(s) → new tab, other schemes left in place.
2283+
doc.querySelectorAll('a').forEach(applyNewTabToAnchor);
22882284

22892285
// Plugin intercept: let plugins cancel or rewrite external links inside
22902286
// the email body before navigation happens. Bound on the iframe doc so
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, it, expect } from 'vitest';
2+
import DOMPurify from 'dompurify';
3+
import {
4+
EMAIL_IFRAME_SANITIZE_CONFIG,
5+
applyNewTabToAnchor,
6+
plainTextToSafeHtml,
7+
sanitizePlainTextRenderedHtml,
8+
parseHtmlSafely,
9+
} from '../email-sanitization';
10+
11+
/**
12+
* Regression guard for "email links open in a new tab". The at-risk links are
13+
* generated by linkification (not in the source) and DOMPurify was silently
14+
* stripping their target/rel, so they opened in the same tab. Drives both real
15+
* EmailViewer pipelines end-to-end — plaintext and HTML/iframe — so it can't
16+
* regress unnoticed.
17+
*/
18+
19+
/** Reproduce the EmailViewer iframe pipeline for an HTML body. */
20+
function renderIframeHtml(html: string): Document {
21+
DOMPurify.addHook('afterSanitizeAttributes', applyNewTabToAnchor);
22+
let clean: string;
23+
try {
24+
clean = DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
25+
} finally {
26+
DOMPurify.removeAllHooks();
27+
}
28+
const doc = parseHtmlSafely(clean);
29+
// Post-render walk, exactly as handleIframeLoad does on the live iframe doc.
30+
doc.querySelectorAll('a').forEach(applyNewTabToAnchor);
31+
return doc;
32+
}
33+
34+
/** Reproduce the EmailViewer plaintext pipeline (rendered into the main DOM). */
35+
function renderPlaintext(text: string): Document {
36+
return parseHtmlSafely(sanitizePlainTextRenderedHtml(plainTextToSafeHtml(text)));
37+
}
38+
39+
const findLink = (doc: Document, hrefIncludes: string): HTMLAnchorElement | undefined =>
40+
Array.from(doc.querySelectorAll('a')).find((a) => (a.getAttribute('href') || '').includes(hrefIncludes));
41+
42+
describe('email link new-tab behaviour (integration)', () => {
43+
describe('plaintext body (links are generated, not in the source)', () => {
44+
it('opens an http(s) URL in a new tab with noopener noreferrer', () => {
45+
const doc = renderPlaintext('Please visit https://example.com/welcome today.');
46+
const link = findLink(doc, 'example.com');
47+
expect(link).toBeTruthy();
48+
expect(link!.getAttribute('href')).toBe('https://example.com/welcome');
49+
expect(link!.getAttribute('target')).toBe('_blank');
50+
expect(link!.getAttribute('rel')).toBe('noopener noreferrer');
51+
});
52+
53+
it('does not turn a bare email address into a new-tab link', () => {
54+
const doc = renderPlaintext('Write to foo@bar.com for help.');
55+
// plaintext linkification only targets http(s) URLs, never mailto.
56+
expect(doc.querySelectorAll('a').length).toBe(0);
57+
});
58+
});
59+
60+
describe('HTML alternative that looks like plaintext (server-generated <a> tags)', () => {
61+
it('opens http(s) anchors in a new tab and adds noopener noreferrer', () => {
62+
const doc = renderIframeHtml('Hi<br><a href="http://example.org/page">http://example.org/page</a><br>Bye');
63+
const link = findLink(doc, 'example.org');
64+
expect(link!.getAttribute('target')).toBe('_blank');
65+
expect(link!.getAttribute('rel')).toBe('noopener noreferrer');
66+
});
67+
68+
it('does NOT add target=_blank to mailto links', () => {
69+
const doc = renderIframeHtml('<a href="mailto:sales@example.com">sales@example.com</a>');
70+
const link = findLink(doc, 'mailto:');
71+
expect(link).toBeTruthy();
72+
expect(link!.getAttribute('target')).toBeNull();
73+
expect(link!.getAttribute('rel')).toBeNull();
74+
});
75+
76+
it('does NOT add target=_blank to in-page #anchors', () => {
77+
const doc = renderIframeHtml('<a href="#section">jump</a>');
78+
const link = findLink(doc, '#section');
79+
expect(link!.getAttribute('target')).toBeNull();
80+
});
81+
82+
it('strips an author-supplied target=_blank from a mailto link', () => {
83+
const doc = renderIframeHtml('<a href="mailto:x@y.com" target="_blank" rel="noopener noreferrer">x</a>');
84+
const link = findLink(doc, 'mailto:');
85+
expect(link!.getAttribute('target')).toBeNull();
86+
});
87+
88+
it('handles a mixed body: http gets a new tab, mailto does not', () => {
89+
const doc = renderIframeHtml(
90+
'See <a href="https://docs.example.com">docs</a> or mail <a href="mailto:hi@example.com">us</a>.',
91+
);
92+
expect(findLink(doc, 'docs.example.com')!.getAttribute('target')).toBe('_blank');
93+
expect(findLink(doc, 'mailto:')!.getAttribute('target')).toBeNull();
94+
});
95+
});
96+
});

lib/__tests__/email-sanitization.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import {
1111
EMAIL_SANITIZE_CONFIG,
1212
EMAIL_IFRAME_SANITIZE_CONFIG,
1313
isExternalResourceUrl,
14+
isHttpLinkHref,
15+
applyNewTabToAnchor,
16+
sanitizeI18nHtml,
1417
decodeCssEscapes,
1518
styleHasExternalUrl,
1619
stripExternalCssUrls,
@@ -360,6 +363,85 @@ describe('email-sanitization', () => {
360363
});
361364
});
362365

366+
describe('isHttpLinkHref (open-in-new-tab eligibility)', () => {
367+
it('treats http(s) and protocol-relative links as new-tab links', () => {
368+
expect(isHttpLinkHref('https://example.com/page')).toBe(true);
369+
expect(isHttpLinkHref('http://example.com/page')).toBe(true);
370+
expect(isHttpLinkHref('//example.com/page')).toBe(true);
371+
expect(isHttpLinkHref('HTTPS://EXAMPLE.COM')).toBe(true);
372+
});
373+
374+
it('sees through obfuscated schemes (leading/embedded whitespace)', () => {
375+
expect(isHttpLinkHref('\n\nhttps://example.com')).toBe(true);
376+
expect(isHttpLinkHref(' \t https://example.com')).toBe(true);
377+
expect(isHttpLinkHref('h\nttps://example.com')).toBe(true);
378+
});
379+
380+
it('excludes mailto and other non-web schemes (must NOT open a new tab)', () => {
381+
expect(isHttpLinkHref('mailto:someone@example.com')).toBe(false);
382+
expect(isHttpLinkHref('mailto:someone@example.com?subject=Hi')).toBe(false);
383+
expect(isHttpLinkHref('tel:+15551234567')).toBe(false);
384+
expect(isHttpLinkHref('sms:+15551234567')).toBe(false);
385+
expect(isHttpLinkHref('cid:image001@example.com')).toBe(false);
386+
expect(isHttpLinkHref('#section')).toBe(false);
387+
expect(isHttpLinkHref('/relative/path')).toBe(false);
388+
expect(isHttpLinkHref('')).toBe(false);
389+
expect(isHttpLinkHref(null)).toBe(false);
390+
expect(isHttpLinkHref(undefined)).toBe(false);
391+
});
392+
});
393+
394+
describe('applyNewTabToAnchor', () => {
395+
const anchor = (html: string): HTMLAnchorElement =>
396+
parseHtmlSafely(html).querySelector('a')!;
397+
398+
it('adds target/rel to http(s) links', () => {
399+
const a = anchor('<a href="https://example.com">x</a>');
400+
applyNewTabToAnchor(a);
401+
expect(a.getAttribute('target')).toBe('_blank');
402+
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
403+
});
404+
405+
it('strips target/rel from mailto links', () => {
406+
const a = anchor('<a href="mailto:a@b.com" target="_blank" rel="noopener noreferrer">x</a>');
407+
applyNewTabToAnchor(a);
408+
expect(a.getAttribute('target')).toBeNull();
409+
expect(a.getAttribute('rel')).toBeNull();
410+
});
411+
412+
it('strips target from tel: and in-page #anchors', () => {
413+
const tel = anchor('<a href="tel:+1555" target="_blank">x</a>');
414+
applyNewTabToAnchor(tel);
415+
expect(tel.getAttribute('target')).toBeNull();
416+
const frag = anchor('<a href="#section" target="_blank">x</a>');
417+
applyNewTabToAnchor(frag);
418+
expect(frag.getAttribute('target')).toBeNull();
419+
});
420+
421+
it('ignores non-anchor elements', () => {
422+
const span = parseHtmlSafely('<span target="_blank">x</span>').querySelector('span')!;
423+
applyNewTabToAnchor(span);
424+
expect(span.getAttribute('target')).toBe('_blank');
425+
});
426+
});
427+
428+
describe('sanitizeI18nHtml', () => {
429+
it('preserves an authored target="_blank" and hardens rel (regression: DOMPurify strips target)', () => {
430+
const out = sanitizeI18nHtml(
431+
'See the <a href="/docs/guides/account-security" class="underline" target="_blank">documentation</a>.',
432+
);
433+
expect(out).toContain('target="_blank"');
434+
expect(out).toContain('rel="noopener noreferrer"');
435+
expect(out).toContain('href="/docs/guides/account-security"');
436+
});
437+
438+
it('leaves links without a target untouched (no spurious new tab)', () => {
439+
const out = sanitizeI18nHtml('Go <a href="/settings">here</a>.');
440+
expect(out).toContain('href="/settings"');
441+
expect(out).not.toContain('target=');
442+
});
443+
});
444+
363445
describe('decodeCssEscapes', () => {
364446
it('decodes hex escapes (cssEscape bypass)', () => {
365447
expect(decodeCssEscapes('\\68ttp://x')).toBe('http://x');

lib/email-sanitization.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,24 @@ const I18N_SANITIZE_CONFIG = {
154154
};
155155

156156
export function sanitizeI18nHtml(html: string): string {
157-
return DOMPurify.sanitize(html, I18N_SANITIZE_CONFIG);
157+
// A custom ALLOWED_URI_REGEXP makes DOMPurify strip target/rel from trusted
158+
// translated links (e.g. settings.security.not_available's docs link); keep
159+
// them, and force rel on _blank to prevent tab-nabbing when the catalog omits it.
160+
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
161+
if (data.attrName === 'target' || data.attrName === 'rel') {
162+
data.forceKeepAttr = true;
163+
}
164+
});
165+
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
166+
if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
167+
node.setAttribute('rel', 'noopener noreferrer');
168+
}
169+
});
170+
try {
171+
return DOMPurify.sanitize(html, I18N_SANITIZE_CONFIG);
172+
} finally {
173+
DOMPurify.removeAllHooks();
174+
}
158175
}
159176

160177
/**
@@ -178,6 +195,8 @@ const PLAIN_TEXT_RENDERED_CONFIG = {
178195
};
179196

180197
export function sanitizePlainTextRenderedHtml(html: string): string {
198+
// target/rel survive the URI check via ADD_URI_SAFE_ATTR (#594); the plaintext
199+
// linkifier only emits http(s), so no per-scheme handling is needed here.
181200
return DOMPurify.sanitize(html, PLAIN_TEXT_RENDERED_CONFIG);
182201
}
183202

@@ -208,6 +227,36 @@ export function isExternalResourceUrl(value: string | null | undefined): boolean
208227
}
209228

210229

230+
/**
231+
* True for external web links (http/https or protocol-relative `//host`) that
232+
* should open in a new tab — unlike `mailto:`/`tel:`/`#fragments`, which navigate
233+
* in place or hand off to the OS handler. Strips C0 controls first so obfuscated
234+
* schemes (`"h\ttps://x"`) don't slip through.
235+
*/
236+
export function isHttpLinkHref(href: string | null | undefined): boolean {
237+
if (!href) return false;
238+
// eslint-disable-next-line no-control-regex
239+
const normalized = href.replace(/[\u0000-\u0020]+/g, '');
240+
return /^(?:https?:\/\/|\/\/)/i.test(normalized);
241+
}
242+
243+
/**
244+
* Give one `<a>` the new-tab treatment uniformly across the iframe render paths
245+
* (the DOMPurify hook and the post-render DOM walk in email-viewer): http(s)
246+
* links get target=_blank + rel; other schemes have them stripped so they don't
247+
* spawn a blank tab. (The plaintext path relies on ADD_URI_SAFE_ATTR instead.)
248+
*/
249+
export function applyNewTabToAnchor(node: Element): void {
250+
if (node.tagName !== 'A') return;
251+
if (isHttpLinkHref(node.getAttribute('href'))) {
252+
node.setAttribute('target', '_blank');
253+
node.setAttribute('rel', 'noopener noreferrer');
254+
} else {
255+
node.removeAttribute('target');
256+
node.removeAttribute('rel');
257+
}
258+
}
259+
211260
/**
212261
* Decode CSS escape sequences so escaped tracking URLs can be recognised.
213262
* `\68ttp://x` and `\000068ttp://x` both decode to `http://x` (the `cssEscape`

0 commit comments

Comments
 (0)