import { describe, expect, it } from 'vitest'; import { EmailQuoteExtractor } from '../emailQuoteExtractor.js'; const SAMPLE_EMAIL_HTML = `

method

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

`; const EMAIL_WITH_SIGNATURE = `

Latest reply here.

Thanks,

Jane Doe

On Mon, Sep 22, Someone wrote:

Previous reply content

`; const EMAIL_WITH_FOLLOW_UP_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


> On Mon, Apr 6, 2026, Shruthi wrote:
> Inline plain-text quote line
> that I made

The original HTML quoted email

`; // Real-world body: Gmail web reply (matches the structure the Chatwoot // fixture in components-next/message/fixtures/emailConversation.js produces). const GMAIL_REAL_WORLD_REPLY = `

Dear Sam,

Thank you for the quotation. Could you share images?

Best,
Alex


On Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test> wrote:

Dear Alex,

Thank you for your inquiry.

Best regards,
Sam

`; // Outlook web/desktop: header div carries id="divRplyFwdMsg" (no quote class) // and the original is wrapped in a bare `
` after it. const OUTLOOK_REAL_WORLD_REPLY = `

Hi team,

See attached the latest proposal.

Regards,
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.

Sam

`; // Yahoo Mail: wraps the previous email in
. const YAHOO_MAIL_REPLY = `
My reply text here.
Thanks,
Pat
On Wednesday, December 4, 2024, 5:15 PM, Sam <sam@example.test> wrote:
Original Yahoo-quoted message body.
`; // Thunderbird: the attribution paragraph carries class="moz-cite-prefix" and // the original lives in
. const THUNDERBIRD_REPLY = `

My reply.

Thanks,
Pat

On 4/12/24 17:15, Sam wrote:

Original Thunderbird-quoted message body.

`; // 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 = `
FYI — see the original below.

---------- 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.
`; // Outlook plain text "----- Original Message -----" header. const OUTLOOK_ORIGINAL_MESSAGE = `

Quick reply.

Thanks

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

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

`; // iPhone Mail / `text/plain` reply, after sanitizeTextForRender() has converted // `\n` → `
` 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 = '

My reply.

-----Original Message-----
From: Sam
Sent: ...

Old body line 1

'; 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 = '

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, 2024

Old 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: foo

Old 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 = `

Pat — please look into this when you get a chance.

Thanks,
Sam

From: Maya <maya@example.test>
Sent: Wednesday, December 4, 2024 8:42 AM
To: Sam <sam@example.test>
Subject: Customer escalation

Sam, Acme Corp is threatening to churn over recent latency issues.

Maya

`; 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 = '

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

-----Original Message-----
From: Sam
Old body
'; 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 mid-paragraph', () => { const html = `

Hey Sam,

The bug ticket says the markdown for -----Original Message----- should render correctly.

// strip on its own line only

Tested 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('Content

'); expect(cleanedHtml).toContain('

More

'); }); it('removes onclick handlers in extractQuotes', () => { const maliciousHtml = '

Click me

'; const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml); expect(cleanedHtml).not.toContain('onclick'); expect(cleanedHtml).toContain('Click me'); }); it('removes javascript: URLs in extractQuotes', () => { const maliciousHtml = 'Link'; const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml); // eslint-disable-next-line no-script-url expect(cleanedHtml).not.toContain('javascript:'); expect(cleanedHtml).toContain('Link'); }); it('removes encoded payloads with event handlers in extractQuotes', () => { const maliciousHtml = ''; const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml); expect(cleanedHtml).not.toContain('onerror'); expect(cleanedHtml).not.toContain('eval'); }); }); });