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

`. const blockText = el => { const tmp = document.createElement('div'); tmp.innerHTML = el.innerHTML.replaceAll(//gi, '\n'); return tmp.textContent; }; const nodeText = n => { if (n.nodeType === TEXT) return n.textContent; if (n.nodeType === ELEM) return blockText(n); return ''; }; // Walk back over leading neutrals so the cut sits at the boundary. const walkBack = (kids, idx) => { let i = idx; while (i > 0 && isNeutral(kids[i - 1])) i -= 1; return i; }; const countHeaderLines = t => t.split('\n').filter(l => HEADER_LINE.test(l)).length; const isSoftHeader = t => ATTRIBUTION.test(t) || countHeaderLines(t) >= 2; const isHardHeader = t => HARD_HEADERS.some(re => re.test(t)); // Find blocks matching `predicate`. Default keeps innermost so outer wrappers // matching via bubbled-up text don't take the user's reply with them. `outer` // keeps outermost — for hard markers, where forwarded body lives in the // wrapper's later siblings. const findBlocks = (root, predicate, { outer = false } = {}) => { const all = [...root.querySelectorAll(BLOCK_SELECTOR)].filter(el => predicate(blockText(el)) ); return outer ? all.filter(el => !all.some(o => o !== el && o.contains(el))) : all.filter(el => !all.some(o => o !== el && el.contains(o))); }; // Strip from the first child whose text matches `marker` (skipping leading // neutrals), then remove every sibling after `block` — that's where the // original-message body lives on forwarded layouts. Drop block if empty. const cutBlockAtMarker = (block, marker) => { const kids = [...block.childNodes]; const idx = kids.findIndex(c => marker(nodeText(c))); const from = idx === -1 ? 0 : walkBack(kids, idx); kids.slice(from).forEach(c => c.remove()); while (block.nextSibling) block.nextSibling.remove(); if (!block.childNodes.length) block.remove(); }; // Nearest enclosing `
` (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. Trailing
as 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; } }