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 = '
Tags';
const doc = parseHtmlSafely(malformed);
expect(doc).toBeInstanceOf(Document);
expect(doc.body.textContent).toContain('Unclosed');
});
});
describe('hasRichFormatting', () => {
it('should detect tables', () => {
const html = '
';
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('
- 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 (
${html}