Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a60d182744 | ||
|
|
38446a1ec0 | ||
|
|
8534fb42fc | ||
|
|
a9424b6c7c | ||
|
|
e44b2fd571 | ||
|
|
28c5bb6a75 | ||
|
|
ce4ea484bd | ||
|
|
4caaa77c61 | ||
|
|
5e21a475a7 | ||
|
|
2a3da3e6e1 | ||
|
|
bfd9fb4bd9 | ||
|
|
761077c1d6 | ||
|
|
a195764c03 | ||
|
|
3f93e4f23a | ||
|
|
e8c85362a1 | ||
|
|
6c40375703 | ||
|
|
85e0dfbfd9 | ||
|
|
890509858d | ||
|
|
193c3b38aa | ||
|
|
ccffef6759 | ||
|
|
8d3eb35ab1 | ||
|
|
c104886a71 | ||
|
|
cbe1b68417 | ||
|
|
91fe8e0e47 | ||
|
|
8dc7247e39 | ||
|
|
5245cc4245 | ||
|
|
c8abbadafc | ||
|
|
19d3680358 | ||
|
|
6e8635e4c9 | ||
|
|
435b38c911 | ||
|
|
90f3365d45 | ||
|
|
f8414bfa4f | ||
|
|
122295b5f4 | ||
|
|
6630eb5c14 | ||
|
|
b8f938b7c3 | ||
|
|
31287976b9 |
@@ -1,6 +1,8 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Quote detection strategies
|
||||
// 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',
|
||||
@@ -10,149 +12,216 @@ const QUOTE_INDICATORS = [
|
||||
'.quote',
|
||||
'[class*="quote"]',
|
||||
'[class*="Quote"]',
|
||||
'.moz-cite-prefix',
|
||||
'.yahoo_quoted',
|
||||
'#divRplyFwdMsg',
|
||||
];
|
||||
|
||||
const BLOCKQUOTE_FALLBACK_SELECTOR = 'blockquote';
|
||||
|
||||
// Regex patterns for quote identification
|
||||
const QUOTE_PATTERNS = [
|
||||
/On .* wrote:/i,
|
||||
/-----Original Message-----/i,
|
||||
/Sent: /i,
|
||||
/From: /i,
|
||||
// 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;
|
||||
|
||||
export class EmailQuoteExtractor {
|
||||
/**
|
||||
* Remove quotes from email HTML and return cleaned HTML
|
||||
* @param {string} htmlContent - Full HTML content of the email
|
||||
* @returns {string} HTML content with quotes removed
|
||||
*/
|
||||
static extractQuotes(htmlContent) {
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
const BLOCK_SELECTOR = 'div, p, blockquote, section';
|
||||
const TEXT = 3; // Node.TEXT_NODE
|
||||
const ELEM = 1; // Node.ELEMENT_NODE
|
||||
|
||||
// Remove elements matching class selectors
|
||||
QUOTE_INDICATORS.forEach(selector => {
|
||||
tempDiv.querySelectorAll(selector).forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
// `<br>` 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');
|
||||
|
||||
this.removeTrailingBlockquote(tempDiv);
|
||||
// Element text with `<br>` rendered as `\n`, so line-anchored regexes match
|
||||
// shapes like `<p>From: Sam<br>Sent: Wed</p>`.
|
||||
const blockText = el => {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = el.innerHTML.replaceAll(/<br\s*\/?>/gi, '\n');
|
||||
return tmp.textContent;
|
||||
};
|
||||
|
||||
// Remove text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
textNodeQuotes.forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
const nodeText = n => {
|
||||
if (n.nodeType === TEXT) return n.textContent;
|
||||
if (n.nodeType === ELEM) return blockText(n);
|
||||
return '';
|
||||
};
|
||||
|
||||
return tempDiv.innerHTML;
|
||||
// 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 `<blockquote>` (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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if HTML content contains any quotes
|
||||
* @param {string} htmlContent - Full HTML content of the email
|
||||
* @returns {boolean} True if quotes are detected, false otherwise
|
||||
*/
|
||||
static hasQuotes(htmlContent) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
// Walk up while `block` is the first substantive child of its parent.
|
||||
// Promotes the cut to the wrapper, so a divider `<div>` 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;
|
||||
};
|
||||
|
||||
// Check for class-based quotes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const selector of QUOTE_INDICATORS) {
|
||||
if (tempDiv.querySelector(selector)) {
|
||||
return true;
|
||||
// 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 + <br>, 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;
|
||||
// Trigger must be the FIRST non-empty line — a buried header inside
|
||||
// a multiline node would otherwise drop the reply above it.
|
||||
const probe = (t.split('\n').find(l => l.trim()) ?? '').trim();
|
||||
if (HARD_HEADERS.some(re => re.test(probe)) || ATTRIBUTION.test(probe)) {
|
||||
// No sibling content above → could be bottom-posted. Fire only when
|
||||
// both the lines BELOW the trigger inside this node AND every
|
||||
// following sibling are `>`-quoted or empty. Otherwise leave alone.
|
||||
if (kids.slice(0, i).every(isNeutral)) {
|
||||
const probeIdx = t.split('\n').findIndex(l => l.trim());
|
||||
const tailHereOk = t
|
||||
.split('\n')
|
||||
.slice(probeIdx + 1)
|
||||
.every(l => !l.trim() || l.trim().startsWith('>'));
|
||||
return (
|
||||
tailHereOk &&
|
||||
kids.slice(i + 1).every(c => isRfcQuoted(c) || isNeutral(c))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.findTrailingBlockquote(tempDiv)) {
|
||||
return true;
|
||||
}
|
||||
return HEADER_LINE.test(probe) && countHeaderLines(tailText(i)) >= 2;
|
||||
});
|
||||
return idx === -1 ? -1 : walkBack(kids, idx);
|
||||
};
|
||||
|
||||
// Check for text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
return textNodeQuotes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find text nodes that match quote patterns
|
||||
* @param {Element} rootElement - Root element to search
|
||||
* @returns {Element[]} Array of parent block elements containing quote-like text
|
||||
*/
|
||||
static findTextNodeQuotes(rootElement) {
|
||||
const quoteBlocks = [];
|
||||
const treeWalker = document.createTreeWalker(
|
||||
rootElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
for (
|
||||
let currentNode = treeWalker.nextNode();
|
||||
currentNode !== null;
|
||||
currentNode = treeWalker.nextNode()
|
||||
) {
|
||||
const isQuoteLike = QUOTE_PATTERNS.some(pattern =>
|
||||
pattern.test(currentNode.textContent)
|
||||
);
|
||||
|
||||
if (isQuoteLike) {
|
||||
const parentBlock = this.findParentBlock(currentNode);
|
||||
if (parentBlock && !quoteBlocks.includes(parentBlock)) {
|
||||
quoteBlocks.push(parentBlock);
|
||||
}
|
||||
}
|
||||
// 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 <blockquote> as the last top-level child.
|
||||
if (root.lastElementChild?.matches?.('blockquote'))
|
||||
root.lastElementChild.remove();
|
||||
// 4. Soft headers. Match inside a <blockquote> → 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());
|
||||
};
|
||||
|
||||
return quoteBlocks;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest block-level parent element by recursively traversing up the DOM tree.
|
||||
* This method searches for common block-level elements like DIV, P, BLOCKQUOTE, and SECTION
|
||||
* that contain the text node. It's used to identify and remove entire block-level elements
|
||||
* that contain quote-like text, rather than just removing the text node itself. This ensures
|
||||
* proper structural removal of quoted content while maintaining HTML integrity.
|
||||
* @param {Node} node - Starting node to find parent
|
||||
* @returns {Element|null} Block-level parent element
|
||||
*/
|
||||
static findParentBlock(node) {
|
||||
const blockElements = ['DIV', 'P', 'BLOCKQUOTE', 'SECTION'];
|
||||
let current = node.parentElement;
|
||||
|
||||
while (current) {
|
||||
if (blockElements.includes(current.tagName)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove fallback blockquote if it is the last top-level element.
|
||||
* @param {Element} rootElement - Root element containing the HTML
|
||||
*/
|
||||
static removeTrailingBlockquote(rootElement) {
|
||||
const trailingBlockquote = this.findTrailingBlockquote(rootElement);
|
||||
trailingBlockquote?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a fallback blockquote that is the last top-level element.
|
||||
* @param {Element} rootElement - Root element containing the HTML
|
||||
* @returns {Element|null} The trailing blockquote element if present
|
||||
*/
|
||||
static findTrailingBlockquote(rootElement) {
|
||||
const lastElement = rootElement.lastElementChild;
|
||||
if (lastElement?.matches?.(BLOCKQUOTE_FALLBACK_SELECTOR)) {
|
||||
return lastElement;
|
||||
}
|
||||
return null;
|
||||
/** True when any strategy would strip something. */
|
||||
static hasQuotes(html) {
|
||||
const root = parse(html);
|
||||
const before = root.innerHTML;
|
||||
apply(root);
|
||||
return root.innerHTML !== before;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EmailQuoteExtractor } from '../emailQuoteExtractor.js';
|
||||
|
||||
const SAMPLE_EMAIL_HTML = `
|
||||
@@ -43,35 +42,31 @@ const EMAIL_WITH_FOLLOW_UP_CONTENT = `
|
||||
<p>Regards,</p>
|
||||
`;
|
||||
|
||||
// Regression coverage for the quote-toggle rewrite shipped on this branch.
|
||||
// Parse cleaned HTML into a container so each test can assert against
|
||||
// .textContent or query nested nodes.
|
||||
const cleaned = html => {
|
||||
const c = document.createElement('div');
|
||||
c.innerHTML = EmailQuoteExtractor.extractQuotes(html);
|
||||
return c;
|
||||
};
|
||||
|
||||
describe('EmailQuoteExtractor', () => {
|
||||
it('removes blockquote-based quotes from the email body', () => {
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(SAMPLE_EMAIL_HTML);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = cleanedHtml;
|
||||
|
||||
expect(container.querySelectorAll('blockquote').length).toBe(0);
|
||||
expect(container.textContent?.trim()).toBe('method');
|
||||
expect(container.textContent).not.toContain(
|
||||
'On Mon, Sep 29, 2025 at 5:18 PM'
|
||||
);
|
||||
const c = cleaned(SAMPLE_EMAIL_HTML);
|
||||
expect(c.querySelectorAll('blockquote').length).toBe(0);
|
||||
expect(c.textContent?.trim()).toBe('method');
|
||||
expect(c.textContent).not.toContain('On Mon, Sep 29, 2025 at 5:18 PM');
|
||||
});
|
||||
|
||||
it('keeps blockquote fallback when it is not the last top-level element', () => {
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(
|
||||
EMAIL_WITH_FOLLOW_UP_CONTENT
|
||||
);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = cleanedHtml;
|
||||
|
||||
expect(container.querySelector('blockquote')).not.toBeNull();
|
||||
expect(container.lastElementChild?.tagName).toBe('P');
|
||||
const c = cleaned(EMAIL_WITH_FOLLOW_UP_CONTENT);
|
||||
expect(c.querySelector('blockquote')).not.toBeNull();
|
||||
expect(c.lastElementChild?.tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('detects quote indicators in nested blockquotes', () => {
|
||||
const result = EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML);
|
||||
expect(result).toBe(true);
|
||||
expect(EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag blockquotes that are followed by other elements', () => {
|
||||
@@ -81,16 +76,14 @@ describe('EmailQuoteExtractor', () => {
|
||||
});
|
||||
|
||||
it('returns false when no quote indicators are present', () => {
|
||||
const html = '<p>Plain content</p>';
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
|
||||
expect(EmailQuoteExtractor.hasQuotes('<p>Plain content</p>')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes trailing blockquotes while preserving trailing signatures', () => {
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE);
|
||||
|
||||
expect(cleanedHtml).toContain('<p>Thanks,</p>');
|
||||
expect(cleanedHtml).toContain('<p>Jane Doe</p>');
|
||||
expect(cleanedHtml).not.toContain('<blockquote');
|
||||
const c = cleaned(EMAIL_WITH_SIGNATURE);
|
||||
expect(c.querySelector('blockquote')).toBeNull();
|
||||
expect(c.textContent).toContain('Thanks,');
|
||||
expect(c.textContent).toContain('Jane Doe');
|
||||
});
|
||||
|
||||
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
|
||||
@@ -150,4 +143,352 @@ describe('EmailQuoteExtractor', () => {
|
||||
expect(cleanedHtml).not.toContain('eval');
|
||||
});
|
||||
});
|
||||
|
||||
describe('client wrappers', () => {
|
||||
it('Gmail — strips .gmail_quote_container with attribution + blockquote', () => {
|
||||
const html = `<div dir="ltr"><p>Dear Sam,</p><p>Thank you for the quotation.</p><p>Best,<br>Alex</p></div><br><div class="gmail_quote gmail_quote_container"><div class="gmail_attr">On Wed, 4 Dec 2024 at 17:15, Sam wrote:<br></div><blockquote class="gmail_quote"><p>Dear Alex,</p><p>Thank you for your inquiry.</p></blockquote></div>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Dear Sam');
|
||||
expect(c.textContent).not.toContain('Thank you for your inquiry');
|
||||
expect(c.querySelector('.gmail_quote')).toBeNull();
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('Outlook — strips #divRplyFwdMsg header AND the trailing bare blockquote', () => {
|
||||
const html = `<p>Hi team,</p><p>Regards,<br>Pat</p><div id="divRplyFwdMsg"><b>From:</b> Sam<br><b>Sent:</b> Wed Dec 4, 2024<br><b>To:</b> Pat<br><b>Subject:</b> Quotation</div><blockquote><p>Hi Pat,</p><p>Quotation attached.</p></blockquote>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Hi team');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(c.textContent).not.toContain('Quotation attached');
|
||||
expect(c.querySelector('blockquote')).toBeNull();
|
||||
expect(c.querySelector('#divRplyFwdMsg')).toBeNull();
|
||||
});
|
||||
|
||||
it('Yahoo — strips .yahoo_quoted wrapper', () => {
|
||||
const html = `<div>My reply text here.</div><div class="yahoo_quoted"><div>On Wed, Dec 4, 2024, Sam wrote:</div><div>Original Yahoo-quoted message body.</div></div>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My reply text here');
|
||||
expect(c.textContent).not.toContain('Original Yahoo-quoted message');
|
||||
expect(c.querySelector('.yahoo_quoted')).toBeNull();
|
||||
});
|
||||
|
||||
it('Thunderbird — strips .moz-cite-prefix attribution AND <blockquote type="cite">', () => {
|
||||
const html = `<p>My reply.</p><p class="moz-cite-prefix">On 4/12/24 17:15, Sam wrote:</p><blockquote type="cite"><p>Original Thunderbird-quoted message body.</p></blockquote>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My reply');
|
||||
expect(c.textContent).not.toContain('On 4/12/24 17:15');
|
||||
expect(c.textContent).not.toContain(
|
||||
'Original Thunderbird-quoted message'
|
||||
);
|
||||
expect(c.querySelector('blockquote')).toBeNull();
|
||||
expect(c.querySelector('.moz-cite-prefix')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hard markers', () => {
|
||||
it('Gmail "---------- Forwarded message ----------" block is stripped', () => {
|
||||
const html = `<div>FYI — see the original below.<div class="gmail_quote"><div>---------- Forwarded message ---------<br>From: Sam<br>Date: Wed, 4 Dec 2024<br>Subject: Quotation<br>To: Pat</div><div>Original forwarded body content.</div></div></div>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('FYI — see the original below');
|
||||
expect(c.textContent).not.toContain('Forwarded message');
|
||||
expect(c.textContent).not.toContain('Original forwarded body content');
|
||||
});
|
||||
|
||||
it('Outlook plain-style "-----Original Message-----" header is stripped', () => {
|
||||
const html = `<p>Quick reply.</p><p>-----Original Message-----<br>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: Quotation</p><p>Original Outlook plain-style reply.</p>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Quick reply');
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(c.textContent).not.toContain('Original Outlook plain-style reply');
|
||||
});
|
||||
|
||||
it('preserves reply text that sits before a hard marker in the SAME block', () => {
|
||||
const html =
|
||||
'<div>My reply<br><br>-----Original Message-----<br>From: Sam<br>Old body</div>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My reply');
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(c.textContent).not.toContain('Old body');
|
||||
});
|
||||
|
||||
it('does not strip when "Original Message" appears inside a sentence', () => {
|
||||
const html =
|
||||
'<p>The bug ticket says the markdown for `-----Original Message-----` should render.</p><p>Here is my fix.</p>';
|
||||
expect(cleaned(html).textContent).toContain('Here is my fix');
|
||||
});
|
||||
|
||||
it('does not strip when "Original Message" sits inside <code> mid-paragraph', () => {
|
||||
const html =
|
||||
'<p>Hey Sam,</p><p>The markdown for <code>-----Original Message-----</code> should render.</p><p>Tested locally.</p><p>Pat</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Hey Sam');
|
||||
expect(c.textContent).toContain('Tested locally');
|
||||
expect(c.textContent).toContain('Pat');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
|
||||
});
|
||||
|
||||
it('cuts wrapper-level siblings when the marker is in a nested child', () => {
|
||||
// Outlook nested shape: marker sits in an inner block, but the actual
|
||||
// forwarded body lives in later siblings of the outer wrapper. Strategy
|
||||
// 2 selects the outer match so the wrapper's later siblings strip too.
|
||||
const html =
|
||||
'<div>' +
|
||||
'<p>My reply.</p>' +
|
||||
'<div class="OutlookHeader"><p>-----Original Message-----</p></div>' +
|
||||
'<p>From: Sam</p>' +
|
||||
'<p>Old body</p>' +
|
||||
'</div>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My reply');
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(c.textContent).not.toContain('Old body');
|
||||
});
|
||||
|
||||
it('treats hard markers as absolute boundaries — sibling tail is always quoted body', () => {
|
||||
// Hard markers (`-----Original Message-----` etc.) are explicit,
|
||||
// anchored boundaries: by convention everything after is the original.
|
||||
// Strategy 4 (soft headers) preserves following siblings because
|
||||
// `From:` can appear in prose; strategy 2 deliberately does not. Locks
|
||||
// down the asymmetry against future "preserve bottom-post" rewrites.
|
||||
const html =
|
||||
'<p>-----Original Message-----</p>' +
|
||||
'<p>From: Sam</p>' +
|
||||
'<p>Old quoted body</p>' +
|
||||
'<p>Reply pretending to be bottom-posted</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(c.textContent).not.toContain('Old quoted body');
|
||||
expect(c.textContent).not.toContain(
|
||||
'Reply pretending to be bottom-posted'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-line attribution headers (From / Sent / To)', () => {
|
||||
it('detects header inside a single outer wrapper <div>', () => {
|
||||
const html =
|
||||
'<div><p>My reply.</p><p>-----Original Message-----<br>From: Sam<br>Sent: ...</p><p>Old body</p></div>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My reply');
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects "From:/Sent:" header even when followed by un-prefixed old lines', () => {
|
||||
const html =
|
||||
'<p>Reply text.</p><p>From: Sam<br>Sent: Wed Dec 4, 2024</p><p>Old line 1</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Reply text');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects minimal "From: name + Sent: weekday" header (no @, no year)', () => {
|
||||
const html =
|
||||
'<p>Reply text.</p><p>From: Sam<br>Sent: Wednesday<br>To: Pat<br>Subject: Re: foo</p><p>Old body</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Reply text');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects header buried in a deep wrapper (Outlook WordSection1)', () => {
|
||||
const html = `
|
||||
<div class="WordSection1">
|
||||
<p>Pat — please look into this.</p>
|
||||
<p>Thanks,<br>Sam</p>
|
||||
<div style="border-top:solid #E1E1E1 1.0pt">
|
||||
<p><b>From:</b> Maya<br><b>Sent:</b> Wed, 4 Dec<br><b>To:</b> Sam<br><b>Subject:</b> Customer escalation</p>
|
||||
</div>
|
||||
<p>Sam, Acme Corp is threatening to churn.</p>
|
||||
</div>
|
||||
`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Pat — please look into this');
|
||||
expect(c.textContent).not.toContain('From: Maya');
|
||||
expect(c.textContent).not.toContain('Subject: Customer escalation');
|
||||
});
|
||||
|
||||
it('strips a flat header at root but keeps the body visible (bottom-post safety)', () => {
|
||||
const html =
|
||||
'<p>Confirming I received this — will review tomorrow.</p>' +
|
||||
'<p>Thanks,<br>Pat</p>' +
|
||||
'<p>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: Quotation</p>' +
|
||||
'<p>Hi Pat,<br>Quotation attached.<br>Sam</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Confirming I received this');
|
||||
expect(c.textContent).toContain('Thanks,');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
});
|
||||
|
||||
it('preserves a bottom-posted reply that follows a flat header block at root', () => {
|
||||
const html =
|
||||
'<p>From: Sam<br>Sent: Wed Dec 4, 2024<br>To: Pat<br>Subject: foo</p>' +
|
||||
'<p>Hi Pat, original message body.</p>' +
|
||||
'<p>--- My reply below ---</p>' +
|
||||
'<p>Got it, thanks!</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Got it, thanks');
|
||||
expect(c.textContent).toContain('My reply below');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
});
|
||||
|
||||
it('strips Apple-Mail blockquote (attribution + body inside one <blockquote type="cite">)', () => {
|
||||
const html =
|
||||
'<div>Sounds good, see you Friday.</div>' +
|
||||
'<div><blockquote type="cite">' +
|
||||
'<div>On Apr 6, 2026, at 11:26 AM, Sam wrote:</div>' +
|
||||
'<div><div>Hi Pat,</div><div>Locking the Friday slot.</div></div>' +
|
||||
'</blockquote></div>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Sounds good');
|
||||
expect(c.textContent).not.toContain('On Apr 6, 2026');
|
||||
expect(c.textContent).not.toContain('Locking the Friday slot');
|
||||
});
|
||||
|
||||
it('preserves user reply that follows a soft-header <blockquote>', () => {
|
||||
const html =
|
||||
'<blockquote>On Mon, Sep 22, Sam wrote:<br>Original quoted line.</blockquote><p>My actual reply.</p>';
|
||||
expect(cleaned(html).textContent).toContain('My actual reply');
|
||||
});
|
||||
|
||||
it('preserves user reply that follows a wrapper containing a soft-header block', () => {
|
||||
const html =
|
||||
'<div><blockquote>On Mon, Sam wrote:</blockquote></div><p>My reply outside the wrapper.</p>';
|
||||
expect(cleaned(html).textContent).toContain(
|
||||
'My reply outside the wrapper'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not strip prose paragraphs that start with "From: " or "Sent: "', () => {
|
||||
const fromHtml =
|
||||
'<p>From: now on, please follow this checklist.</p><p>This is regular content.</p>';
|
||||
expect(cleaned(fromHtml).textContent).toContain('From: now on');
|
||||
expect(cleaned(fromHtml).textContent).toContain('regular content');
|
||||
|
||||
const sentHtml =
|
||||
'<p>Sent: yesterday by the courier.</p><p>Tracking number to follow.</p>';
|
||||
expect(cleaned(sentHtml).textContent).toContain('Sent: yesterday');
|
||||
expect(cleaned(sentHtml).textContent).toContain('Tracking number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-line "On … wrote:" attribution', () => {
|
||||
it('detects header even when followed by un-prefixed old lines', () => {
|
||||
const html =
|
||||
'<p>On Wed, Sam wrote:</p><p>Old line 1</p><p>Old line 2</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).not.toContain('On Wed, Sam wrote');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('top-level RFC `>` and header tail (text + <br>)', () => {
|
||||
it('iPhone Mail — strips RFC `>` quoted lines from a plain-text only body', () => {
|
||||
const html = [
|
||||
'Test payments email<br><br>',
|
||||
'Thanks,<br>Shruthi<br><br>',
|
||||
'Sent from my iPhone<br><br>',
|
||||
'> On Apr 6, 2026, at 11:26 PM, Shruthi wrote:<br>',
|
||||
'> <br>> Hi email<br>> To Eli<br>',
|
||||
'> Thanks,<br>> Shruthi',
|
||||
].join('');
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Test payments email');
|
||||
expect(c.textContent).toContain('Sent from my iPhone'); // signature stays
|
||||
expect(c.textContent).not.toContain('On Apr 6, 2026');
|
||||
expect(c.textContent).not.toContain('Hi email');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('strips both plain-text `>` lines and HTML quote blocks in the same body', () => {
|
||||
const html = `<p>My HTML reply</p>
|
||||
<p>Sivin</p>
|
||||
<br>
|
||||
> On Mon, Apr 6, 2026, Shruthi wrote:<br>
|
||||
> Inline plain-text quote line<br>
|
||||
<div class="gmail_quote">
|
||||
<p>The original HTML quoted email</p>
|
||||
</div>`;
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('My HTML reply');
|
||||
expect(c.textContent).toContain('Sivin');
|
||||
expect(c.textContent).not.toContain('Inline plain-text quote line');
|
||||
expect(c.textContent).not.toContain('The original HTML quoted email');
|
||||
expect(c.querySelectorAll('.gmail_quote').length).toBe(0);
|
||||
});
|
||||
|
||||
it('detects top-level "On … wrote:" header (no wrapper)', () => {
|
||||
const html =
|
||||
'Reply text<br><br>On Tue, Pat wrote:<br>Original line 1<br>Original line 2';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Reply text');
|
||||
expect(c.textContent).not.toContain('On Tue, Pat wrote');
|
||||
expect(c.textContent).not.toContain('Original line 1');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects top-level "From:/Sent:" header (no wrapper)', () => {
|
||||
const html =
|
||||
'Reply text<br>From: Sam<br>Sent: Wed Dec 4, 2024<br>Original body';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Reply text');
|
||||
expect(c.textContent).not.toContain('From: Sam');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects top-level "-----Original Message-----" (no wrapper)', () => {
|
||||
const html = 'Reply<br>-----Original Message-----<br>From: Sam<br>Body';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Reply');
|
||||
expect(c.textContent).not.toContain('Original Message');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves user reply that is bottom-posted below `>`-quoted lines', () => {
|
||||
const html =
|
||||
'> On Tue, Pat wrote:<br>> Attached is the doc.<br>> Pat<br><br>Got it, looks good.';
|
||||
expect(cleaned(html).textContent).toContain('Got it, looks good');
|
||||
});
|
||||
|
||||
it('preserves user answers inline-posted between `>`-quoted lines', () => {
|
||||
const html =
|
||||
'> Q1: pricing?<br>A1: USD 100<br>> Q2: timeline?<br>A2: 2 weeks<br><br>Thanks!';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('A1: USD 100');
|
||||
expect(c.textContent).toContain('A2: 2 weeks');
|
||||
expect(c.textContent).toContain('Thanks!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline / no-quote bodies', () => {
|
||||
it('inline reply — when content follows the quoted block, body is left intact', () => {
|
||||
const html =
|
||||
'<p>See my responses inline below.</p><blockquote><p>Q1: pricing?</p><p>A1: usd 100.</p></blockquote><p>Let me know if any of that needs clarification.</p><p>Pat</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('See my responses inline below');
|
||||
expect(c.textContent).toContain('Q1: pricing?');
|
||||
expect(c.textContent).toContain(
|
||||
'Let me know if any of that needs clarification'
|
||||
);
|
||||
expect(c.querySelector('blockquote')).not.toBeNull();
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
|
||||
});
|
||||
|
||||
it('plain body with no quotes — body unchanged, no toggle', () => {
|
||||
const html =
|
||||
'<p>Just checking in — any update on this?</p><p>Thanks,<br>Pat</p>';
|
||||
const c = cleaned(html);
|
||||
expect(c.textContent).toContain('Just checking in');
|
||||
expect(c.textContent).toContain('Thanks,');
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
|
||||
});
|
||||
|
||||
it('empty body — no error, no toggle', () => {
|
||||
expect(() => EmailQuoteExtractor.extractQuotes('')).not.toThrow();
|
||||
expect(EmailQuoteExtractor.hasQuotes('')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user