From 91fe8e0e47701bd1c18461edefb4aaab60d7496d Mon Sep 17 00:00:00 2001 From: iamsivin Date: Wed, 29 Apr 2026 23:30:37 +0530 Subject: [PATCH] chore: Minor fix --- .../dashboard/helper/emailQuoteExtractor.js | 77 ++++++++----------- .../helper/specs/emailQuoteExtractor.spec.js | 21 ++++- 2 files changed, 50 insertions(+), 48 deletions(-) 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

`. +// 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'); @@ -55,8 +51,7 @@ const nodeText = n => { return ''; }; -// Walk back over leading neutrals so the cut sits at the boundary, not in -// the middle of a `
` separator. +// 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; @@ -65,7 +60,6 @@ const walkBack = (kids, idx) => { 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)); @@ -80,8 +74,8 @@ const findBlocks = (root, predicate) => { }; // Strip from the first child whose text matches `marker` (skipping leading -// neutrals), then remove every sibling after `block` — the original-message -// body lives there on forwarded layouts. Drop `block` if it ends empty. +// 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))); @@ -91,8 +85,7 @@ const cutBlockAtMarker = (block, marker) => { if (!block.childNodes.length) block.remove(); }; -// Walk up to the nearest enclosing `
` (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. Trailing
as 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: foo

' + + '

Hi 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,
Pat

' + '

From: Sam <sam@example.test>
Sent: Wednesday, December 4, 2024 5:15 PM
To: Pat <pat@example.test>
Subject: Quotation

' + - '

Hi Pat,
Quotation attached. Let me know if you need changes.
Sam

'; + '

Hi Pat,
Quotation attached.
Sam

'; 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 `
` is followed by the