diff --git a/app/javascript/dashboard/helper/emailQuoteExtractor.js b/app/javascript/dashboard/helper/emailQuoteExtractor.js
index cf21183bb..325a4ea6f 100644
--- a/app/javascript/dashboard/helper/emailQuoteExtractor.js
+++ b/app/javascript/dashboard/helper/emailQuoteExtractor.js
@@ -1,8 +1,8 @@
import DOMPurify from 'dompurify';
-// Wrapper classes mail clients put around the quoted reply.
-// Removing these depth-agnostically covers Gmail, Outlook, Yahoo,
-// Thunderbird, ProtonMail, Apple Mail signatures, etc.
+// 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',
@@ -17,22 +17,18 @@ const QUOTE_INDICATORS = [
'#divRplyFwdMsg',
];
-// Full-line forwarded-section markers. Anchored so prose containing the
-// phrase mid-sentence can't false-trigger a strip.
+// 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. A block needs >= 2 such lines to count, so a
-// single prose line like "From: now on, please …" can't false-trigger.
+// 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
@@ -41,8 +37,8 @@ const isNeutral = n =>
(n.nodeType === TEXT && !n.textContent.trim()) ||
(n.nodeType === ELEM && n.tagName === 'BR');
-// Read element text with `
` rendered as `\n`, so line-anchored regexes
-// match shapes like `
From: Sam
Sent: Wed
From: Sam
Sent: Wed
` (including `block` itself). -// Returns null when there is none below `root`. +// Nearest enclosing `` (including `block` itself), or null. const findEnclosingBlockquote = (block, root) => { let cur = block; while (cur && cur !== root) { @@ -103,8 +96,8 @@ const findEnclosingBlockquote = (block, root) => { }; // Walk up while `block` is the first substantive child of its parent. -// Promotes the cut to the wrapper, so a divider `` plus the body -// siblings AFTER it strip together. +// 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) { @@ -125,10 +118,9 @@ const isRfcQuoted = 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 -// (preserves bottom/inline posting). A header-line text node needs the -// joined tail to carry >= 2 header lines. +// 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 => @@ -151,38 +143,33 @@ const findTopLevelTailStart = root => { return idx === -1 ? -1 : walkBack(kids, idx); }; -// Five strategies, each independent. Run in order. +// 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 block + every following sibling. + // 2. Hard markers — cut block + every following sibling. findBlocks(root, isHardHeader).forEach(b => cutBlockAtMarker(b, isHardHeader) ); // 3. Trailingas the last top-level child. if (root.lastElementChild?.matches?.('blockquote')) root.lastElementChild.remove(); - // 4. Soft headers. Three sub-cases: - // (a) match sits inside a— remove the whole blockquote - // (it wraps the entire quote: attribution + body). Apple Mail. - // (b) match has >= 2 header lines (real Outlook attribution shape) — - // hard-cut at the wrapper level so the body siblings go too. - // (c) just an "On … wrote:" attribution → develop-style remove the - // block, so a user reply sitting at root level isn't eaten. + // 4. Soft headers. Match inside a→ remove that blockquote + // (Apple Mail wraps attribution + body together). Match inside an outer + // wrapper (WordSection1 shape) → hard-cut at the wrapper. Flat layout at + // root → just block.remove(); body and bottom-posted reply look identical + // so leave following siblings alone. findBlocks(root, isSoftHeader).forEach(block => { - const enclosingBq = findEnclosingBlockquote(block, root); - if (enclosingBq) { - enclosingBq.remove(); - return; - } - if (countHeaderLines(blockText(block)) >= 2) { - cutBlockAtMarker( - expandToWrapper(block, root), + const bq = findEnclosingBlockquote(block, root); + if (bq) return bq.remove(); + const cutPoint = expandToWrapper(block, root); + if (cutPoint !== block) { + return cutBlockAtMarker( + cutPoint, t => HEADER_LINE.test(t) || ATTRIBUTION.test(t) ); - return; } - block.remove(); + return block.remove(); }); // 5. Top-level RFC `>` / header tail. const start = findTopLevelTailStart(root); @@ -196,7 +183,7 @@ const parse = html => { }; export class EmailQuoteExtractor { - /** Strip the quoted-reply tail and return the cleaned HTML. */ + /** Strip the quoted-reply tail and return cleaned HTML. */ static extractQuotes(html) { const root = parse(html); apply(root); diff --git a/app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js b/app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js index 51d5a12a6..369f1b7c2 100644 --- a/app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js +++ b/app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js @@ -310,6 +310,19 @@ describe('EmailQuoteExtractor', () => { expect(cleaned).not.toContain('From: Sam'); }); + it('preserves a bottom-posted reply that follows a header block at root', () => { + const html = + 'From: Sam <sam@example.test>
' + + '
Sent: Wednesday, December 4, 2024
To: Pat
Subject: fooHi Pat, original message body.
' + + '--- My reply below ---
' + + 'Got it, thanks!
'; + const c = document.createElement('div'); + c.innerHTML = EmailQuoteExtractor.extractQuotes(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)', () => { const html = 'Sounds good, see you Friday.' + @@ -325,18 +338,20 @@ describe('EmailQuoteExtractor', () => { expect(c.textContent).not.toContain('Locking the Friday slot'); }); - it('strips flat Outlook attribution + body (multi-line From/Sent/To/Subject)', () => { + // Flat Outlook header at root — strip the header block, keep the body + // visible. We can't tell the body apart from a bottom-posted reply at + // root level, so be safe (matches develop behaviour). + it('strips a flat Outlook header block but keeps body visible', () => { const html = 'Confirming I received this — will review tomorrow.
' + 'Thanks,
' + '
PatFrom: Sam <sam@example.test>
' + - '
Sent: Wednesday, December 4, 2024 5:15 PM
To: Pat <pat@example.test>
Subject: QuotationHi Pat,
'; + '
Quotation attached. Let me know if you need changes.
SamHi Pat,
'; const c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(html); expect(c.textContent).toContain('Confirming I received this'); expect(c.textContent).toContain('Thanks,'); expect(c.textContent).not.toContain('From: Sam'); - expect(c.textContent).not.toContain('Quotation attached'); }); // Inline reply where a soft-header `
Quotation attached.
Sam` is followed by the