import DOMPurify from 'dompurify';
// Wrapper classes mail clients use around the quoted reply.
// Removing them depth-agnostically covers Gmail, Outlook, Yahoo, Thunderbird,
// ProtonMail, Apple Mail signatures, etc.
const QUOTE_INDICATORS = [
'.gmail_quote_container',
'.gmail_quote',
'.OutlookQuote',
'.email-quote',
'.quoted-text',
'.quote',
'[class*="quote"]',
'[class*="Quote"]',
'.moz-cite-prefix',
'.yahoo_quoted',
'#divRplyFwdMsg',
];
// Full-line forwarded markers — anchored so prose can't false-trigger.
const HARD_HEADERS = [
/^\s*-+\s*Original Message\s*-+\s*$/im,
/^\s*-+\s*Forwarded message\s*-+\s*$/im,
/^\s*Begin forwarded message:\s*$/im,
];
const ATTRIBUTION = /^On .* wrote:/im;
// One Outlook header field. Block needs >= 2 such lines to count, so prose
// like "From: now on, please …" can't false-trigger.
const HEADER_LINE = /^(?:From|Sent|To|Cc|Bcc|Date|Subject):\s/im;
const BLOCK_SELECTOR = 'div, p, blockquote, section';
const TEXT = 3; // Node.TEXT_NODE
const ELEM = 1; // Node.ELEMENT_NODE
// `
` and whitespace-only text — sit inside a tail, never start one.
const isNeutral = n =>
(n.nodeType === TEXT && !n.textContent.trim()) ||
(n.nodeType === ELEM && n.tagName === 'BR');
// Element text with `
` rendered as `\n`, so line-anchored regexes match
// shapes like `
From: Sam
Sent: Wed
` (including `block` itself), or null. const findEnclosingBlockquote = (block, root) => { let cur = block; while (cur && cur !== root) { if (cur.tagName === 'BLOCKQUOTE') return cur; cur = cur.parentElement; } return null; }; // Walk up while `block` is the first substantive child of its parent. // Promotes the cut to the wrapper, so a divider `` plus body siblings // AFTER it strip together. const expandToWrapper = (block, root) => { let cur = block; while (cur.parentElement && cur.parentElement !== root) { const kids = [...cur.parentElement.childNodes]; const before = kids.slice(0, kids.indexOf(cur)); if (before.some(c => !isNeutral(c) && c.textContent.trim())) break; cur = cur.parentElement; } return cur; }; // Every visible line of the text node begins with `>`. const isRfcQuoted = n => n.nodeType === TEXT && !!n.textContent.trim() && n.textContent .split('\n') .filter(l => l.trim()) .every(l => l.trim().startsWith('>')); // Top-level (text +
, no block wrapper) tail-start index. RFC `>` only // fires when every following node is `>`-quoted or neutral. A header-line // text node needs the joined tail to carry >= 2 header lines. const findTopLevelTailStart = root => { const kids = [...root.childNodes]; const tailText = i => kids .slice(i) .map(n => { if (n.nodeType === TEXT) return n.textContent; if (n.nodeType !== ELEM) return ''; return n.tagName === 'BR' ? '\n' : blockText(n); }) .join(''); const idx = kids.findIndex((n, i) => { if (isRfcQuoted(n)) return kids.slice(i).every(c => isRfcQuoted(c) || isNeutral(c)); if (isNeutral(n)) return false; const t = nodeText(n); if (!t.trim()) return false; if (HARD_HEADERS.some(re => re.test(t)) || ATTRIBUTION.test(t)) { // No reply text above the trigger → could be bottom-posted. Mirror // the RFC branch: only fire when every following node is `>`-quoted // or neutral. Otherwise leave the body alone. if (kids.slice(0, i).every(isNeutral)) return kids.slice(i + 1).every(c => isRfcQuoted(c) || isNeutral(c)); return true; } return HEADER_LINE.test(t) && countHeaderLines(tailText(i)) >= 2; }); return idx === -1 ? -1 : walkBack(kids, idx); }; // Five additive strategies, each independent. Run in order. const apply = root => { // 1. Strip every known quote-wrapper class. root.querySelectorAll(QUOTE_INDICATORS.join(',')).forEach(el => el.remove()); // 2. Hard markers — cut at the outer wrapper so its later siblings (the // forwarded body) go with it. findBlocks(root, isHardHeader, { outer: true }).forEach(b => cutBlockAtMarker(b, isHardHeader) ); // 3. Trailingas the last top-level child. if (root.lastElementChild?.matches?.('blockquote')) root.lastElementChild.remove(); // 4. Soft headers. Match inside a→ remove that blockquote // (Apple Mail wraps attribution + body together). Match inside a nested // outer wrapper (WordSection1 shape) → hard-cut at the wrapper. At root, // strip trailing siblings only when reply content sits ABOVE the trigger // — header-first at root could be a bottom-posted reply, so leave it. findBlocks(root, isSoftHeader).forEach(block => { const bq = findEnclosingBlockquote(block, root); if (bq) return bq.remove(); const marker = t => HEADER_LINE.test(t) || ATTRIBUTION.test(t); const cutPoint = expandToWrapper(block, root); if (cutPoint !== block && cutPoint.parentElement !== root) { return cutBlockAtMarker(cutPoint, marker); } const kids = [...root.childNodes]; const hasPrecedingContent = kids .slice(0, kids.indexOf(block)) .some(n => !isNeutral(n) && n.textContent.trim()); if (hasPrecedingContent) return cutBlockAtMarker(block, marker); return block.remove(); }); // 5. Top-level RFC `>` / header tail. const start = findTopLevelTailStart(root); if (start !== -1) [...root.childNodes].slice(start).forEach(n => n.remove()); }; const parse = html => { const root = document.createElement('div'); root.innerHTML = DOMPurify.sanitize(html); return root; }; export class EmailQuoteExtractor { /** Strip the quoted-reply tail and return cleaned HTML. */ static extractQuotes(html) { const root = parse(html); apply(root); return root.innerHTML; } /** True when any strategy would strip something. */ static hasQuotes(html) { const root = parse(html); const before = root.innerHTML; apply(root); return root.innerHTML !== before; } }