import { describe, it, expect } from 'vitest'; import DOMPurify from 'dompurify'; import { sanitizeEmailHtml, sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, parseHtmlSafely, hasRichFormatting, plainTextToSafeHtml, sanitizePlainTextRenderedHtml, EMAIL_SANITIZE_CONFIG, EMAIL_IFRAME_SANITIZE_CONFIG, isExternalResourceUrl, isHttpLinkHref, applyNewTabToAnchor, sanitizeI18nHtml, decodeCssEscapes, styleHasExternalUrl, stripExternalCssUrls, stripExternalStyleSheetCss, blockExternalResourcesOnNode, TRANSPARENT_BLOCKED_PIXEL, } from '../email-sanitization'; describe('email-sanitization', () => { describe('sanitizeEmailHtml', () => { it('should remove script tags', () => { const malicious = '

Hello

'; const clean = sanitizeEmailHtml(malicious); expect(clean).not.toContain(''; const clean = sanitizeSignatureHtml(malicious); expect(clean).not.toContain(''; parseHtmlSafely(html); expect(executed).toBe(false); }); it('should handle malformed HTML gracefully', () => { const malformed = '

Unclosed

Tags'; const doc = parseHtmlSafely(malformed); expect(doc).toBeInstanceOf(Document); expect(doc.body.textContent).toContain('Unclosed'); }); }); describe('hasRichFormatting', () => { it('should detect tables', () => { const html = '
Data
'; expect(hasRichFormatting(html)).toBe(true); }); it('should detect images', () => { const html = ''; expect(hasRichFormatting(html)).toBe(true); }); it('should detect inline styles', () => { const html = '
Styled
'; expect(hasRichFormatting(html)).toBe(true); }); it('should detect formatting tags', () => { expect(hasRichFormatting('Bold')).toBe(true); expect(hasRichFormatting('Strong')).toBe(true); expect(hasRichFormatting('Emphasized')).toBe(true); }); it('should detect headings', () => { expect(hasRichFormatting('

Title

')).toBe(true); expect(hasRichFormatting('

Subtitle

')).toBe(true); }); it('should detect lists', () => { expect(hasRichFormatting('')).toBe(true); expect(hasRichFormatting('
  1. Item
')).toBe(true); }); it('should return false for plain text', () => { const plain = '

Just plain text

'; expect(hasRichFormatting(plain)).toBe(false); }); it('should return false for simple paragraphs', () => { const simple = '

Line 1

Line 2

'; expect(hasRichFormatting(simple)).toBe(false); }); it('should handle empty HTML', () => { expect(hasRichFormatting('')).toBe(false); expect(hasRichFormatting(' ')).toBe(false); }); }); describe('inline CID image handling', () => { it('should preserve blob: URLs for CID-replaced images (not treated as external)', () => { // Simulate what the component does: replace cid: with blob: object URLs const html = '

See image:

'; const clean = sanitizeEmailHtml(html); expect(clean).toContain('blob:'); }); it('should preserve data: URLs for CID placeholder images', () => { const html = ''; const clean = sanitizeEmailHtml(html); expect(clean).toContain('data:image/gif'); }); it('should not leave raw JMAP download URLs after CID replacement pattern', () => { // This tests the regex pattern used for CID replacement const htmlWithCid = ''; // Simulate the component's replacement: all cid: refs should become blob: or data: URLs const replaced = htmlWithCid.replace( /\bcid:([^"'\s)]+)/gi, () => 'blob:http://localhost/safe-object-url' ); expect(replaced).not.toContain('cid:'); expect(replaced).toContain('blob:'); }); it('should block external http(s) images but not blob/data URLs via DOMPurify hook', () => { const html = ` `; const config = { ...EMAIL_SANITIZE_CONFIG }; DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (node.tagName === 'IMG') { const src = node.getAttribute('src'); if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) { node.setAttribute('data-blocked-src', src); node.removeAttribute('src'); node.setAttribute('alt', '[Image blocked]'); } } }); const clean = DOMPurify.sanitize(html, config); DOMPurify.removeAllHooks(); // External https image should be blocked expect(clean).toContain('data-blocked-src'); expect(clean).toContain('tracker.evil.com'); // blob: and data: URLs should NOT be blocked (they don't start with http/https) expect(clean).toContain('blob:'); expect(clean).toContain('data:image/gif'); }); }); describe('isExternalResourceUrl', () => { it('detects http(s) and protocol-relative URLs', () => { expect(isExternalResourceUrl('https://tracker.example/p.png')).toBe(true); expect(isExternalResourceUrl('http://tracker.example/p.png')).toBe(true); expect(isExternalResourceUrl('//tracker.example/p.png')).toBe(true); }); it('sees through leading whitespace/newlines (imgNewlineSrc bypass)', () => { expect(isExternalResourceUrl('\n\nhttps://tracker.example/p.png')).toBe(true); expect(isExternalResourceUrl(' \t https://tracker.example/p.png')).toBe(true); // Tab/newline removed anywhere in the URL by the parser. expect(isExternalResourceUrl('h\nttps://tracker.example/p.png')).toBe(true); expect(isExternalResourceUrl('ht\ttps://tracker.example/p.png')).toBe(true); }); it('treats inline/local schemes as not external', () => { expect(isExternalResourceUrl('data:image/png;base64,AAAA')).toBe(false); expect(isExternalResourceUrl('blob:http://localhost/abc')).toBe(false); expect(isExternalResourceUrl('cid:image001@example.com')).toBe(false); expect(isExternalResourceUrl('/relative/path.png')).toBe(false); expect(isExternalResourceUrl('')).toBe(false); expect(isExternalResourceUrl(null)).toBe(false); expect(isExternalResourceUrl(undefined)).toBe(false); }); }); describe('isHttpLinkHref (open-in-new-tab eligibility)', () => { it('treats http(s) and protocol-relative links as new-tab links', () => { expect(isHttpLinkHref('https://example.com/page')).toBe(true); expect(isHttpLinkHref('http://example.com/page')).toBe(true); expect(isHttpLinkHref('//example.com/page')).toBe(true); expect(isHttpLinkHref('HTTPS://EXAMPLE.COM')).toBe(true); }); it('sees through obfuscated schemes (leading/embedded whitespace)', () => { expect(isHttpLinkHref('\n\nhttps://example.com')).toBe(true); expect(isHttpLinkHref(' \t https://example.com')).toBe(true); expect(isHttpLinkHref('h\nttps://example.com')).toBe(true); }); it('excludes mailto and other non-web schemes (must NOT open a new tab)', () => { expect(isHttpLinkHref('mailto:someone@example.com')).toBe(false); expect(isHttpLinkHref('mailto:someone@example.com?subject=Hi')).toBe(false); expect(isHttpLinkHref('tel:+15551234567')).toBe(false); expect(isHttpLinkHref('sms:+15551234567')).toBe(false); expect(isHttpLinkHref('cid:image001@example.com')).toBe(false); expect(isHttpLinkHref('#section')).toBe(false); expect(isHttpLinkHref('/relative/path')).toBe(false); expect(isHttpLinkHref('')).toBe(false); expect(isHttpLinkHref(null)).toBe(false); expect(isHttpLinkHref(undefined)).toBe(false); }); }); describe('applyNewTabToAnchor', () => { const anchor = (html: string): HTMLAnchorElement => parseHtmlSafely(html).querySelector('a')!; it('adds target/rel to http(s) links', () => { const a = anchor('x'); applyNewTabToAnchor(a); expect(a.getAttribute('target')).toBe('_blank'); expect(a.getAttribute('rel')).toBe('noopener noreferrer'); }); it('strips target/rel from mailto links', () => { const a = anchor('x'); applyNewTabToAnchor(a); expect(a.getAttribute('target')).toBeNull(); expect(a.getAttribute('rel')).toBeNull(); }); it('strips target from tel: and in-page #anchors', () => { const tel = anchor('x'); applyNewTabToAnchor(tel); expect(tel.getAttribute('target')).toBeNull(); const frag = anchor('x'); applyNewTabToAnchor(frag); expect(frag.getAttribute('target')).toBeNull(); }); it('ignores non-anchor elements', () => { const span = parseHtmlSafely('x').querySelector('span')!; applyNewTabToAnchor(span); expect(span.getAttribute('target')).toBe('_blank'); }); }); describe('sanitizeI18nHtml', () => { it('preserves an authored target="_blank" and hardens rel (regression: DOMPurify strips target)', () => { const out = sanitizeI18nHtml( 'See the documentation.', ); expect(out).toContain('target="_blank"'); expect(out).toContain('rel="noopener noreferrer"'); expect(out).toContain('href="/docs/guides/account-security"'); }); it('leaves links without a target untouched (no spurious new tab)', () => { const out = sanitizeI18nHtml('Go here.'); expect(out).toContain('href="/settings"'); expect(out).not.toContain('target='); }); }); describe('decodeCssEscapes', () => { it('decodes hex escapes (cssEscape bypass)', () => { expect(decodeCssEscapes('\\68ttp://x')).toBe('http://x'); expect(decodeCssEscapes('\\000068ttps://x')).toBe('https://x'); // Hex escape consumes one trailing whitespace separator. expect(decodeCssEscapes('\\68 ttp')).toBe('http'); }); it('decodes single-character escapes', () => { expect(decodeCssEscapes('\\h\\t\\t\\p')).toBe('http'); }); }); describe('styleHasExternalUrl / stripExternalCssUrls', () => { it('detects and strips plain external url()', () => { const style = 'background:url(https://tracker.example/p.png)'; expect(styleHasExternalUrl(style)).toBe(true); expect(stripExternalCssUrls(style)).toBe('background:url()'); }); it('detects and strips CSS-escaped external url()', () => { const style = 'background:url(\\68ttps://tracker.example/p.png)'; expect(styleHasExternalUrl(style)).toBe(true); expect(stripExternalCssUrls(style)).toBe('background:url()'); }); it('detects url() with whitespace/quotes', () => { expect(styleHasExternalUrl("background: url( '\n https://t/p.png' )")).toBe(true); }); it('leaves data: and relative url() untouched', () => { const style = "background:url('data:image/png;base64,AAAA')"; expect(styleHasExternalUrl(style)).toBe(false); expect(stripExternalCssUrls(style)).toBe(style); }); }); describe('stripExternalStyleSheetCss (', ).body.firstElementChild!; expect(blockExternalResourcesOnNode(style)).toBe(true); expect(style.textContent).toBe('#x{background:url()}'); }); }); describe('blockExternalResourcesOnNode (anti-tracking vectors)', () => { function el(html: string): Element { return parseHtmlSafely(`${html}`).body.firstElementChild!; } it('blocks an img whose src is hidden behind a leading newline', () => { const img = el(''); img.setAttribute('src', '\n\nhttps://tracker.example/pixel.png'); expect(blockExternalResourcesOnNode(img)).toBe(true); expect(img.getAttribute('data-blocked-src')).toBe('https://tracker.example/pixel.png'); expect(img.getAttribute('src')).toBe(TRANSPARENT_BLOCKED_PIXEL); }); it('blocks img srcset', () => { const img = el(''); expect(blockExternalResourcesOnNode(img)).toBe(true); expect(img.hasAttribute('srcset')).toBe(false); expect(img.getAttribute('data-blocked-srcset')).toContain('tracker.example'); }); it('blocks (pictureSource)', () => { const source = el(''); expect(blockExternalResourcesOnNode(source)).toBe(true); expect(source.hasAttribute('srcset')).toBe(false); }); it('blocks for media', () => { const source = el(''); expect(blockExternalResourcesOnNode(source)).toBe(true); expect(source.hasAttribute('src')).toBe(false); expect(source.getAttribute('data-blocked-src')).toContain('tracker.example'); }); it('blocks