import { describe, expect, it } from 'vitest'; import { EmailQuoteExtractor } from '../emailQuoteExtractor.js'; const SAMPLE_EMAIL_HTML = `
method
`; const EMAIL_WITH_SIGNATURE = `On Mon, Sep 29, 2025 at 5:18 PM John shivam@chatwoot.com wrote:
Hi
On Mon, Sep 29, 2025 at 5:17 PM Shivam Mishra shivam@chatwoot.com wrote:
Yes, it is.
On Mon, Sep 29, 2025 at 5:16 PM John from Shaneforwoot < shaneforwoot@gmail.com> wrote:
Hey
On Mon, Sep 29, 2025 at 4:59 PM John shivam@chatwoot.com wrote:
This is another quoted quoted text reply
This is nice
On Mon, Sep 29, 2025 at 4:21 PM John from Shaneforwoot < > shaneforwoot@gmail.com> wrote:
Hey there, this is a reply from Chatwoot, notice the quoted text
Hey there
This is an email text, enjoy reading this
-- Shivam Mishra, Chatwoot
Latest reply here.
Thanks,
Jane Doe
`; const EMAIL_WITH_FOLLOW_UP_CONTENT = `On Mon, Sep 22, Someone wrote:
Previous reply content
Inline quote that should stay
Internal note follows
Regards,
`; // Real-world mixed body: an HTML reply, then RFC-style `>`-prefixed plain-text // quote lines, and finally the original email wrapped in a Gmail blockquote. // The current branchy implementation picks ONE strategy and misses the other. const MIXED_PLAINTEXT_AND_HTML_QUOTE = `My HTML reply
Thanks,
Sivin
The original HTML quoted email
Dear Sam,
Thank you for the quotation. Could you share images?
Best,
Alex
Dear Alex,
Thank you for your inquiry.
Best regards,
Sam
` after it. const OUTLOOK_REAL_WORLD_REPLY = `Hi team,
See attached the latest proposal.
Regards,
PatFrom: Sam <sam@example.test>
Sent: Wednesday, December 4, 2024 5:15 PM
To: Pat <pat@example.test>
Subject: Quotation`; // Yahoo Mail: wraps the previous email inHi Pat,
Quotation attached.
Sam
. const YAHOO_MAIL_REPLY = `My reply text here.Thanks,
Pat`; // Thunderbird: the attribution paragraph carries class="moz-cite-prefix" and // the original lives inOn Wednesday, December 4, 2024, 5:15 PM, Sam <sam@example.test> wrote:Original Yahoo-quoted message body.. const THUNDERBIRD_REPLY = `My reply.
Thanks,
PatOn 4/12/24 17:15, Sam wrote:
`; // Gmail forwarded message — the body reads "---------- Forwarded message ---------" // in plain text, with a header block beneath listing From/Date/Subject/To. const GMAIL_FORWARDED_MESSAGE = `Original Thunderbird-quoted message body.
FYI — see the original below.`; // Outlook plain text "----- Original Message -----" header. const OUTLOOK_ORIGINAL_MESSAGE = `---------- Forwarded message ---------
From: Sam <sam@example.test>
Date: Wed, 4 Dec 2024 at 17:15
Subject: Quotation
To: Pat <pat@example.test>Original forwarded body content.Quick reply.
Thanks
-----Original Message-----
From: Sam <sam@example.test>
Sent: Wednesday, December 4, 2024 5:15 PM
To: Pat <pat@example.test>
Subject: QuotationOriginal Outlook plain-style reply.
`; // Inline reply: bare(no client class) sits in the middle, with // content following it. Trailing-only rule should preserve the blockquote. const INLINE_REPLY = `See my responses inline below.
Question 1: pricing?
Answer: usd 100.
Question 2: timeline?
Answer: 2 weeks.
Let me know if any of that needs clarification.
Pat
`; // Plain conversational body with no quote markers — must not be mistakenly // stripped and must not show the toggle. const NO_QUOTE_BODY = `Just checking in — any update on this?
Thanks,
`; // iPhone Mail / `text/plain` reply, after sanitizeTextForRender() has converted // `\n` → `
Pat
` and escaped lone `< > &`. const IPHONE_MAIL_PLAINTEXT = [ 'Test payments email
', 'Thanks,
Shruthi
', 'Sent from my iPhone
', '> On Apr 6, 2026, at 11:26 PM, Shruthi M ', '<shruthi.rohini.7@gmail.com> wrote:
', '>
> Hi email
> To Eli
', '> Thanks,
> Shruthi
', '>
> Sent from my iPhone', ].join(''); describe('EmailQuoteExtractor', () => { describe('real-world client shapes', () => { it('Gmail web reply — strips .gmail_quote_container with attribution + blockquote', () => { const cleaned = EmailQuoteExtractor.extractQuotes(GMAIL_REAL_WORLD_REPLY); const c = document.createElement('div'); c.innerHTML = cleaned; expect(c.textContent).toContain('Dear Sam'); expect(c.textContent).toContain('Best,'); expect(c.textContent).not.toContain('Thank you for your inquiry'); expect(c.textContent).not.toContain('On Wed, 4 Dec 2024'); expect(c.querySelector('.gmail_quote')).toBeNull(); expect(EmailQuoteExtractor.hasQuotes(GMAIL_REAL_WORLD_REPLY)).toBe(true); }); it('Outlook reply — strips #divRplyFwdMsg header AND the trailing bare blockquote', () => { const cleaned = EmailQuoteExtractor.extractQuotes( OUTLOOK_REAL_WORLD_REPLY ); const c = document.createElement('div'); c.innerHTML = cleaned; expect(c.textContent).toContain('Hi team'); expect(c.textContent).toContain('Regards,'); expect(c.textContent).not.toContain('From: Sam'); expect(c.textContent).not.toContain('Quotation attached'); expect(c.querySelector('blockquote')).toBeNull(); expect(c.querySelector('#divRplyFwdMsg')).toBeNull(); expect(EmailQuoteExtractor.hasQuotes(OUTLOOK_REAL_WORLD_REPLY)).toBe( true ); }); it('Yahoo Mail reply — strips .yahoo_quoted wrapper', () => { const cleaned = EmailQuoteExtractor.extractQuotes(YAHOO_MAIL_REPLY); const c = document.createElement('div'); c.innerHTML = cleaned; expect(c.textContent).toContain('My reply text here'); expect(c.textContent).not.toContain('Original Yahoo-quoted message'); expect(c.querySelector('.yahoo_quoted')).toBeNull(); expect(EmailQuoteExtractor.hasQuotes(YAHOO_MAIL_REPLY)).toBe(true); }); it('Thunderbird reply — strips .moz-cite-prefix attribution AND', () => { const cleaned = EmailQuoteExtractor.extractQuotes(THUNDERBIRD_REPLY); const c = document.createElement('div'); c.innerHTML = cleaned; expect(c.textContent).toContain('My reply'); expect(c.textContent).toContain('Thanks,'); 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(); expect(EmailQuoteExtractor.hasQuotes(THUNDERBIRD_REPLY)).toBe(true); }); it('Gmail forwarded message — strips "---------- Forwarded message ----------" block', () => { const cleaned = EmailQuoteExtractor.extractQuotes( GMAIL_FORWARDED_MESSAGE ); const c = document.createElement('div'); c.innerHTML = cleaned; 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'); expect(EmailQuoteExtractor.hasQuotes(GMAIL_FORWARDED_MESSAGE)).toBe(true); }); it('Outlook plain-style "-----Original Message-----" header is stripped', () => { const cleaned = EmailQuoteExtractor.extractQuotes( OUTLOOK_ORIGINAL_MESSAGE ); const c = document.createElement('div'); c.innerHTML = cleaned; expect(c.textContent).toContain('Quick reply'); expect(c.textContent).not.toContain('Original Message'); expect(c.textContent).not.toContain('Original Outlook plain-style reply'); expect(EmailQuoteExtractor.hasQuotes(OUTLOOK_ORIGINAL_MESSAGE)).toBe( true ); }); it('inline reply — when content follows the quoted block, the body is left intact', () => { const cleaned = EmailQuoteExtractor.extractQuotes(INLINE_REPLY); const c = document.createElement('div'); c.innerHTML = cleaned; expect(c.textContent).toContain('See my responses inline below'); expect(c.textContent).toContain('Question 1: pricing?'); expect(c.textContent).toContain( 'Let me know if any of that needs clarification' ); expect(c.querySelector('blockquote')).not.toBeNull(); expect(EmailQuoteExtractor.hasQuotes(INLINE_REPLY)).toBe(false); }); it('plain body with no quotes — body unchanged, no toggle', () => { const cleaned = EmailQuoteExtractor.extractQuotes(NO_QUOTE_BODY); const c = document.createElement('div'); c.innerHTML = cleaned; expect(c.textContent).toContain('Just checking in'); expect(c.textContent).toContain('Thanks,'); expect(EmailQuoteExtractor.hasQuotes(NO_QUOTE_BODY)).toBe(false); }); it('empty body — no error, no toggle', () => { expect(() => EmailQuoteExtractor.extractQuotes('')).not.toThrow(); expect(EmailQuoteExtractor.hasQuotes('')).toBe(false); }); }); // Regression tests — develop-baseline behaviours that earlier rewrites broke. describe('develop-baseline regression coverage', () => { it('detects header quote inside a single outer wrapper', () => { const html = ''; expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true); const cleaned = EmailQuoteExtractor.extractQuotes(html); expect(cleaned).toContain('My reply'); expect(cleaned).not.toContain('Original Message'); }); it('detects "On … wrote:" header even when followed by un-prefixed old lines', () => { const html = 'My reply.
-----Original Message-----
From: Sam
Sent: ...Old body line 1
On Wed, Sam wrote:
Old line 1
Old line 2
'; expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true); const cleaned = EmailQuoteExtractor.extractQuotes(html); expect(cleaned).not.toContain('On Wed, Sam wrote'); }); it('detects "From:/Sent:" header even when followed by un-prefixed old lines', () => { const html = 'Reply text.
From: Sam <sam@example.test>
Sent: Wednesday, December 4, 2024Old line 1
'; expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true); const cleaned = EmailQuoteExtractor.extractQuotes(html); expect(cleaned).toContain('Reply text'); expect(cleaned).not.toContain('From: Sam'); }); // Header markers at the TOP LEVEL — text +
shape with no block // wrapper. The marker's nearest block ancestor is root itself. it('detects top-level "On … wrote:" header (no wrapper)', () => { const html = 'Reply text
On Tue, Pat wrote:
Original line 1
Original line 2'; expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true); const c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(html); expect(c.textContent).toContain('Reply text'); expect(c.textContent).not.toContain('On Tue, Pat wrote'); expect(c.textContent).not.toContain('Original line 1'); }); it('detects top-level "From:/Sent:" header (no wrapper)', () => { const html = 'Reply text
From: Sam <sam@example.test>
Sent: Wednesday, December 4, 2024
Original body'; expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true); const c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(html); expect(c.textContent).toContain('Reply text'); expect(c.textContent).not.toContain('From: Sam'); }); it('detects top-level "-----Original Message-----" (no wrapper)', () => { const html = 'Reply
-----Original Message-----
From: Sam
Body'; expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true); const c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(html); expect(c.textContent).toContain('Reply'); expect(c.textContent).not.toContain('Original Message'); }); // Trailing-only rule: only strip the `>`-block when nothing substantive // follows it. Otherwise the user's own bottom-posted / inline reply is // silently dropped. it('preserves user reply that is bottom-posted below `>`-quoted lines', () => { const html = '> On Tue, Pat wrote:
> Attached is the doc.
> Pat
Got it, looks good.'; const c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(html); expect(c.textContent).toContain('Got it, looks good'); }); it('preserves user answers inline-posted between `>`-quoted lines', () => { const html = '> Q1: pricing?
A1: USD 100
> Q2: timeline?
A2: 2 weeks
Thanks!'; const c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(html); expect(c.textContent).toContain('A1: USD 100'); expect(c.textContent).toContain('A2: 2 weeks'); expect(c.textContent).toContain('Thanks!'); }); // Anchored hard-header patterns: don't strip when the marker phrase shows // up inside a sentence (false trigger). it('does not strip when "Original Message" appears inside a sentence', () => { const html = 'The bug ticket says the markdown for `-----Original Message-----` should render correctly.
Here is my fix.
'; const c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(html); expect(c.textContent).toContain('Here is my fix'); }); it('detects minimal "From: name + Sent: weekday" header (no @, no year)', () => { const html = 'Reply text.
From: Sam
Sent: Wednesday
To: Pat
Subject: Re: fooOld body
'; expect(EmailQuoteExtractor.hasQuotes(html)).toBe(true); const cleaned = EmailQuoteExtractor.extractQuotes(html); expect(cleaned).toContain('Reply text'); expect(cleaned).not.toContain('From: Sam'); }); it('preserves reply when the From-header sits inside a deep wrapper (Outlook WordSection1)', () => { const html = ``; const c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(html); expect(c.textContent).toContain('Pat — please look into this'); expect(c.textContent).toContain('Thanks'); expect(c.textContent).not.toContain('From: Maya'); expect(c.textContent).not.toContain('Subject: Customer escalation'); }); it('does not strip prose paragraphs that start with "From: " or "Sent: "', () => { const fromHtml = 'Pat — please look into this when you get a chance.
Thanks,
SamFrom: Maya <maya@example.test>
Sent: Wednesday, December 4, 2024 8:42 AM
To: Sam <sam@example.test>
Subject: Customer escalationSam, Acme Corp is threatening to churn over recent latency issues.
Maya
From: now on, please follow this checklist.
This is regular content.
'; let c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(fromHtml); expect(c.textContent).toContain('From: now on'); expect(c.textContent).toContain('regular content'); const sentHtml = 'Sent: yesterday by the courier.
Tracking number to follow.
'; c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(sentHtml); expect(c.textContent).toContain('Sent: yesterday'); expect(c.textContent).toContain('Tracking number'); }); it('preserves reply text that sits before a hard marker in the SAME block', () => { const html = 'My reply'; const c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(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" sits inside
-----Original Message-----
From: Sam
Old bodymid-paragraph', () => { const html = `Hey Sam,
The bug ticket says the markdown for
-----Original Message-----should render correctly.// strip on its own line onlyTested locally — passing all cases.
Pat
`; const c = document.createElement('div'); c.innerHTML = EmailQuoteExtractor.extractQuotes(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('strips RFC-style `>` quoted lines from a plain-text only body (iPhone Mail)', () => { const cleanedHtml = EmailQuoteExtractor.extractQuotes( IPHONE_MAIL_PLAINTEXT ); const container = document.createElement('div'); container.innerHTML = cleanedHtml; const text = container.textContent; expect(text).toContain('Test payments email'); expect(text).toContain('Sent from my iPhone'); // signature stays expect(text).not.toContain('On Apr 6, 2026'); expect(text).not.toContain('Hi email'); expect(text).not.toContain('To Eli'); expect(EmailQuoteExtractor.hasQuotes(IPHONE_MAIL_PLAINTEXT)).toBe(true); }); it('strips both plain-text `>` lines and HTML quote blocks in the same body', () => { const cleanedHtml = EmailQuoteExtractor.extractQuotes( MIXED_PLAINTEXT_AND_HTML_QUOTE ); const container = document.createElement('div'); container.innerHTML = cleanedHtml; const text = container.textContent; // Reply portion stays expect(text).toContain('My HTML reply'); expect(text).toContain('Sivin'); // Plain-text `>` quote lines are gone expect(text).not.toContain('Inline plain-text quote line'); expect(text).not.toContain('On Mon, Apr 6, 2026, Shruthi wrote'); // HTML quote block is gone expect(text).not.toContain('The original HTML quoted email'); expect(container.querySelectorAll('.gmail_quote').length).toBe(0); }); 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' ); }); 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'); }); it('detects quote indicators in nested blockquotes', () => { const result = EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML); expect(result).toBe(true); }); it('does not flag blockquotes that are followed by other elements', () => { expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_FOLLOW_UP_CONTENT)).toBe( false ); }); it('returns false when no quote indicators are present', () => { const html = 'Plain content
'; expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false); }); it('removes trailing blockquotes while preserving trailing signatures', () => { const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE); expect(cleanedHtml).toContain('Thanks,
'); expect(cleanedHtml).toContain('Jane Doe
'); expect(cleanedHtml).not.toContain('{ expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true); }); describe('HTML sanitization', () => { it('removes onerror handlers from img tags in extractQuotes', () => { const maliciousHtml = 'Hello
'; const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml); expect(cleanedHtml).not.toContain('onerror'); expect(cleanedHtml).toContain('
Hello
'); }); it('removes onerror handlers from img tags in hasQuotes', () => { const maliciousHtml = 'Hello
'; // Should not throw and should safely check for quotes const result = EmailQuoteExtractor.hasQuotes(maliciousHtml); expect(result).toBe(false); }); it('removes script tags in extractQuotes', () => { const maliciousHtml = '
Content
More
'; const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml); expect(cleanedHtml).not.toContain('