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.
This commit is contained in:
committed by
Linus Rath
parent
f51ec50443
commit
d5017a211f
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import DOMPurify from 'dompurify';
|
||||
import {
|
||||
EMAIL_IFRAME_SANITIZE_CONFIG,
|
||||
applyNewTabToAnchor,
|
||||
plainTextToSafeHtml,
|
||||
sanitizePlainTextRenderedHtml,
|
||||
parseHtmlSafely,
|
||||
} from '../email-sanitization';
|
||||
|
||||
/**
|
||||
* Regression guard for "email links open in a new tab". The at-risk links are
|
||||
* generated by linkification (not in the source) and DOMPurify was silently
|
||||
* stripping their target/rel, so they opened in the same tab. Drives both real
|
||||
* EmailViewer pipelines end-to-end — plaintext and HTML/iframe — so it can't
|
||||
* regress unnoticed.
|
||||
*/
|
||||
|
||||
/** Reproduce the EmailViewer iframe pipeline for an HTML body. */
|
||||
function renderIframeHtml(html: string): Document {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', applyNewTabToAnchor);
|
||||
let clean: string;
|
||||
try {
|
||||
clean = DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
|
||||
} finally {
|
||||
DOMPurify.removeAllHooks();
|
||||
}
|
||||
const doc = parseHtmlSafely(clean);
|
||||
// Post-render walk, exactly as handleIframeLoad does on the live iframe doc.
|
||||
doc.querySelectorAll('a').forEach(applyNewTabToAnchor);
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Reproduce the EmailViewer plaintext pipeline (rendered into the main DOM). */
|
||||
function renderPlaintext(text: string): Document {
|
||||
return parseHtmlSafely(sanitizePlainTextRenderedHtml(plainTextToSafeHtml(text)));
|
||||
}
|
||||
|
||||
const findLink = (doc: Document, hrefIncludes: string): HTMLAnchorElement | undefined =>
|
||||
Array.from(doc.querySelectorAll('a')).find((a) => (a.getAttribute('href') || '').includes(hrefIncludes));
|
||||
|
||||
describe('email link new-tab behaviour (integration)', () => {
|
||||
describe('plaintext body (links are generated, not in the source)', () => {
|
||||
it('opens an http(s) URL in a new tab with noopener noreferrer', () => {
|
||||
const doc = renderPlaintext('Please visit https://example.com/welcome today.');
|
||||
const link = findLink(doc, 'example.com');
|
||||
expect(link).toBeTruthy();
|
||||
expect(link!.getAttribute('href')).toBe('https://example.com/welcome');
|
||||
expect(link!.getAttribute('target')).toBe('_blank');
|
||||
expect(link!.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('does not turn a bare email address into a new-tab link', () => {
|
||||
const doc = renderPlaintext('Write to foo@bar.com for help.');
|
||||
// plaintext linkification only targets http(s) URLs, never mailto.
|
||||
expect(doc.querySelectorAll('a').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML alternative that looks like plaintext (server-generated <a> tags)', () => {
|
||||
it('opens http(s) anchors in a new tab and adds noopener noreferrer', () => {
|
||||
const doc = renderIframeHtml('Hi<br><a href="http://example.org/page">http://example.org/page</a><br>Bye');
|
||||
const link = findLink(doc, 'example.org');
|
||||
expect(link!.getAttribute('target')).toBe('_blank');
|
||||
expect(link!.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('does NOT add target=_blank to mailto links', () => {
|
||||
const doc = renderIframeHtml('<a href="mailto:sales@example.com">sales@example.com</a>');
|
||||
const link = findLink(doc, 'mailto:');
|
||||
expect(link).toBeTruthy();
|
||||
expect(link!.getAttribute('target')).toBeNull();
|
||||
expect(link!.getAttribute('rel')).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT add target=_blank to in-page #anchors', () => {
|
||||
const doc = renderIframeHtml('<a href="#section">jump</a>');
|
||||
const link = findLink(doc, '#section');
|
||||
expect(link!.getAttribute('target')).toBeNull();
|
||||
});
|
||||
|
||||
it('strips an author-supplied target=_blank from a mailto link', () => {
|
||||
const doc = renderIframeHtml('<a href="mailto:x@y.com" target="_blank" rel="noopener noreferrer">x</a>');
|
||||
const link = findLink(doc, 'mailto:');
|
||||
expect(link!.getAttribute('target')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles a mixed body: http gets a new tab, mailto does not', () => {
|
||||
const doc = renderIframeHtml(
|
||||
'See <a href="https://docs.example.com">docs</a> or mail <a href="mailto:hi@example.com">us</a>.',
|
||||
);
|
||||
expect(findLink(doc, 'docs.example.com')!.getAttribute('target')).toBe('_blank');
|
||||
expect(findLink(doc, 'mailto:')!.getAttribute('target')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user