fix(x): collapse quoted replies in Outlook-authored email

Quoted-chain detection only matched Gmail/Apple markup (.gmail_quote,
.gmail_attr, blockquote[type=cite]). Outlook emits none of those, so
hasQuote stayed false, no "•••" toggle rendered, and the whole quoted
history was drawn inline — each message in a long thread re-rendering
every message before it.

Outlook desktop marks the boundary with a border-top:solid #E1E1E1 div
that holds only the From:/Sent:/Subject: header; the quoted body trails
it as siblings. Hiding just the matched element is therefore not enough,
so mark the divider and everything after it, climbing ancestors. Require
the header block before treating a border-top div as a boundary —
signatures and marketing footers draw the same rule, and wrongly hiding a
signature is worse than wrongly showing a quote. Measured against a
558-message local corpus: 7/7 real quotes caught, 0 false positives.

Also fixes a pre-existing bug: a forward is quoted top to bottom, so
hiding its quote left a blank body behind a toggle nobody would press.
When nothing visible remains, show everything and drop the toggle.

The plain-text path had the same Gmail-only hole, and the fix already
existed in the file — isReplyQuoteBoundary handled Outlook headers but
was wired only to the composer. It now backs display too.

Quote tagging moves into the srcDoc build, so quotes are hidden on first
paint and the measured iframe height is the collapsed height.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-07-09 18:15:13 +05:30
parent 1d16d16f15
commit 992c1883a7
3 changed files with 345 additions and 73 deletions

View file

@ -7,6 +7,7 @@ import Placeholder from '@tiptap/extension-placeholder'
import type { blocks } from '@x/shared'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { prepareEmailHtml, splitPlainTextQuote, stripQuotedReplyText, QUOTED_CLASS } from '@/lib/email-quotes'
import { useTheme } from '@/contexts/theme-context'
import { SettingsDialog } from '@/components/settings-dialog'
import { Button } from '@/components/ui/button'
@ -72,31 +73,6 @@ function snippet(text?: string): string {
return (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
}
function isReplyQuoteBoundary(lines: string[], index: number): boolean {
const line = lines[index]?.trim() || ''
if (/^On\b.+\bwrote:\s*$/i.test(line)) return true
if (/^-{2,}\s*(Original Message|Forwarded message)\s*-{2,}$/i.test(line)) return true
if (/^From:\s+\S/i.test(line)) {
const next = lines.slice(index + 1, index + 6).map((value) => value.trim())
return next.some((value) => /^(Sent|Date):\s+\S/i.test(value))
&& next.some((value) => /^To:\s+\S/i.test(value))
&& next.some((value) => /^Subject:\s+\S/i.test(value))
}
return false
}
function stripQuotedReplyText(text: string): string {
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
const boundary = lines.findIndex((line, index) => {
if (isReplyQuoteBoundary(lines, index)) return true
return index > 0
&& line.trim().startsWith('>')
&& (lines[index - 1]?.trim() === '' || lines[index - 1]?.trim().startsWith('>'))
})
const visible = boundary >= 0 ? lines.slice(0, boundary) : lines
return visible.join('\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
}
function getInitial(from?: string): string {
return (extractName(from)[0] || '?').toUpperCase()
}
@ -307,43 +283,6 @@ function plainTextToHtml(text: string): string {
.join('')
}
function splitPlainTextQuote(text: string): { visible: string; quoted: string | null } {
const re = /(?:^|\n)On\s+.+?\swrote:\s*(?:\n|$)/
const match = re.exec(text)
if (!match) return { visible: text, quoted: null }
const start = match.index === 0 ? 0 : match.index + 1
const visible = text.slice(0, start).trimEnd()
const quoted = text.slice(start)
if (!quoted.trim()) return { visible: text, quoted: null }
return { visible, quoted }
}
// True if the HTML — after stripping quoted/hidden content — defines its
// own visual layout (real images, tables, explicit backgrounds). Unstyled
// HTML (Gmail replies, Outlook one-liners wrapped in MsoNormal boilerplate,
// outreach emails with only a tracking pixel, reply HTML whose only image
// lives inside the inline-quoted thread) gets an iframe that adapts to the
// app theme; styled HTML keeps the white "paper" look so newsletters /
// branded designs render as their senders intended.
function isStyledHtml(html: string): boolean {
const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll('.gmail_quote, .gmail_attr, blockquote[type="cite"]').forEach((n) => n.remove())
if (doc.querySelector('table')) return true
for (const img of Array.from(doc.querySelectorAll('img'))) {
const w = parseInt(img.getAttribute('width') || '0', 10)
const h = parseInt(img.getAttribute('height') || '0', 10)
if (w === 1 && h === 1) continue
const style = img.getAttribute('style') || ''
if (/display\s*:\s*none/i.test(style)) continue
if (/visibility\s*:\s*hidden/i.test(style)) continue
return true
}
const visible = doc.body?.innerHTML || ''
if (/bgcolor\s*=/i.test(visible)) return true
if (/background-(color|image)\s*:/i.test(visible)) return true
return false
}
function buildEmailDocument(
html: string,
opts: { theme: 'light' | 'dark'; adaptToTheme: boolean },
@ -384,12 +323,8 @@ function buildEmailDocument(
border-left: 2px solid ${quoteBorder};
color: ${quoteColor};
}
.gmail_quote,
.gmail_attr,
blockquote[type="cite"] { display: none; }
[data-show-quotes="true"] .gmail_quote,
[data-show-quotes="true"] .gmail_attr,
[data-show-quotes="true"] blockquote[type="cite"] { display: block; }
.${QUOTED_CLASS} { display: none; }
[data-show-quotes="true"] .${QUOTED_CLASS} { display: revert; }
</style>
</head><body>${html}</body></html>`
}
@ -436,24 +371,25 @@ function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; t
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastSavedHeightRef = useRef<number>(message.bodyHeight ?? 0)
const [height, setHeight] = useState(message.bodyHeight ?? 80)
const [hasQuote, setHasQuote] = useState(false)
const [showQuotes, setShowQuotes] = useState(false)
// Read by handleLoad so a reload (theme switch rebuilds srcDoc) restores the
// expanded-quotes state on the fresh document.
const showQuotesRef = useRef(showQuotes)
useEffect(() => { showQuotesRef.current = showQuotes }, [showQuotes])
const adaptToTheme = useMemo(() => !isStyledHtml(message.bodyHtml!), [message.bodyHtml])
// Tag the quotes before the iframe ever paints, so quoted history is hidden
// on the first frame and the height we measure is the collapsed height.
const { html, hasQuote, styled } = useMemo(() => prepareEmailHtml(message.bodyHtml!), [message.bodyHtml])
const adaptToTheme = !styled
const srcDoc = useMemo(
() => buildEmailDocument(message.bodyHtml!, { theme: resolvedTheme, adaptToTheme }),
[message.bodyHtml, resolvedTheme, adaptToTheme],
() => buildEmailDocument(html, { theme: resolvedTheme, adaptToTheme }),
[html, resolvedTheme, adaptToTheme],
)
const handleLoad = useCallback(() => {
const iframe = iframeRef.current
const doc = iframe?.contentDocument
if (!doc?.body) return
setHasQuote(!!doc.querySelector('.gmail_quote, .gmail_attr, blockquote[type="cite"]'))
if (showQuotesRef.current) doc.documentElement.dataset.showQuotes = 'true'
// Clicking into the email body focuses the iframe document, which would
// otherwise swallow every list/thread shortcut (the parent's document

View file

@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import { prepareEmailHtml, QUOTED_CLASS, splitPlainTextQuote, stripQuotedReplyText } from './email-quotes'
/** Visible text once the stylesheet hides the tagged nodes. */
function visible(html: string): string {
const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll(`.${QUOTED_CLASS}`).forEach((n) => n.remove())
return (doc.body?.textContent || '').replace(/\s+/g, ' ').trim()
}
// The divider Outlook desktop emits: the header lives inside it, the quoted
// chain trails it as siblings.
const OUTLOOK_QUOTE = `
<div style="border:none;border-top:solid #E1E1E1 1.0pt;padding:3.0pt 0in 0in 0in">
<p class="MsoNormal"><b>From:</b> Counsel &lt;c@firm.com&gt;<br>
<b>Sent:</b> Tuesday, July 7, 2026 9:14 AM<br>
<b>To:</b> Me &lt;me@co.com&gt;<br>
<b>Subject:</b> Re: Series A docs</p>
</div>
<p class="MsoNormal">The indemnity clause needs revisiting.</p>`
describe('prepareEmailHtml — quote detection', () => {
it('hides an Outlook quoted chain, including the siblings after the divider', () => {
const { html, hasQuote } = prepareEmailHtml(
`<p class="MsoNormal">Agreed, see below.</p>${OUTLOOK_QUOTE}`,
)
expect(hasQuote).toBe(true)
expect(visible(html)).toBe('Agreed, see below.')
})
it('hides an Outlook quote nested beside a signature in an outer wrapper', () => {
const { html, hasQuote } = prepareEmailHtml(
`<div><p>Sounds good.</p><p>-- Jane, Partner</p><div>${OUTLOOK_QUOTE}</div></div>`,
)
expect(hasQuote).toBe(true)
expect(visible(html)).toContain('Sounds good.')
expect(visible(html)).toContain('-- Jane, Partner')
expect(visible(html)).not.toContain('indemnity')
})
it('hides Outlook-on-the-web quoted chains via divRplyFwdMsg', () => {
const { html, hasQuote } = prepareEmailHtml(
`<div>Short reply.</div><div id="divRplyFwdMsg"><b>From:</b> C</div><div>Older content.</div>`,
)
expect(hasQuote).toBe(true)
expect(visible(html)).toBe('Short reply.')
})
it('still hides Gmail wrapper quotes', () => {
const { html, hasQuote } = prepareEmailHtml(
`<div>Thanks!</div><div class="gmail_quote"><div class="gmail_attr">On Tue, X wrote:</div><blockquote>old</blockquote></div>`,
)
expect(hasQuote).toBe(true)
expect(visible(html)).toBe('Thanks!')
})
it('leaves a border-top divider alone when it is a signature rule, not a quote header', () => {
const html = `<p>Hi there.</p><div style="border-top:solid #E1E1E1 1.0pt"><p>Jane Doe | Acme | jane@acme.com</p></div>`
const prepared = prepareEmailHtml(html)
expect(prepared.hasQuote).toBe(false)
expect(visible(prepared.html)).toContain('Jane Doe')
})
it('shows a forward in full rather than collapsing the whole body', () => {
// Everything is quoted, so hiding would leave a blank message.
const prepared = prepareEmailHtml(
`<div class="gmail_quote"><div class="gmail_attr">---------- Forwarded message ---------</div><div>The actual content.</div></div>`,
)
expect(prepared.hasQuote).toBe(false)
expect(visible(prepared.html)).toContain('The actual content.')
})
it('reports no quote for a plain message', () => {
expect(prepareEmailHtml('<p>Just a note.</p>').hasQuote).toBe(false)
})
it('keeps a <style> block the parser would otherwise hoist into <head>', () => {
const { html } = prepareEmailHtml('<style>.a{color:red}</style><p>Body</p>')
expect(html).toContain('.a{color:red}')
})
it('ignores quoted content when deciding whether the email is styled', () => {
// The table lives only in the quote, so the body should still adapt to theme.
const { styled } = prepareEmailHtml(
`<p>ok</p><div class="gmail_quote"><table><tr><td>old</td></tr></table></div>`,
)
expect(styled).toBe(false)
})
})
describe('splitPlainTextQuote', () => {
it('splits on an Outlook From:/Sent:/To:/Subject: header block', () => {
const body = [
'Agreed.',
'',
'From: Counsel <c@firm.com>',
'Sent: Tuesday, July 7, 2026 9:14 AM',
'To: Me <me@co.com>',
'Subject: Re: Series A docs',
'',
'The indemnity clause needs revisiting.',
].join('\n')
const { visible: head, quoted } = splitPlainTextQuote(body)
expect(head).toBe('Agreed.')
expect(quoted).toContain('The indemnity clause')
})
it('splits on "On … wrote:"', () => {
const { visible: head, quoted } = splitPlainTextQuote('Thanks!\n\nOn Tue, Jul 7, X wrote:\n> old')
expect(head).toBe('Thanks!')
expect(quoted).toContain('> old')
})
it('splits on a forwarded-message separator', () => {
const { visible: head, quoted } = splitPlainTextQuote('FYI\n\n---------- Forwarded message ---------\nbody')
expect(head).toBe('FYI')
expect(quoted).toContain('body')
})
it('returns no quote when there is no boundary', () => {
expect(splitPlainTextQuote('Just a note.').quoted).toBeNull()
})
it('does not treat a bare "From:" line as a boundary', () => {
expect(splitPlainTextQuote('From: the desk of Jane\n\nHello.').quoted).toBeNull()
})
})
describe('stripQuotedReplyText', () => {
it('drops an Outlook quoted tail', () => {
const body = 'Agreed.\n\nFrom: C <c@f.com>\nSent: Tue\nTo: Me\nSubject: Re: x\n\nold'
expect(stripQuotedReplyText(body)).toBe('Agreed.')
})
it('drops a ">" quoted tail', () => {
expect(stripQuotedReplyText('Reply.\n\n> old line')).toBe('Reply.')
})
})

View file

@ -0,0 +1,198 @@
/**
* Quoted-reply detection for rendered email bodies.
*
* Quoted chains come in two shapes, and the difference decides how they hide:
*
* - Wrapper quotes (Gmail, Apple Mail, Yahoo, Proton): a single element
* contains the whole quoted chain, so hiding that element is enough.
* - Boundary quotes (Outlook, Thunderbird): a divider element holds only the
* "From:/Sent:/Subject:" header and the quoted chain trails it as siblings,
* so everything from the divider onward has to be hidden.
*
* Treating the second kind as the first is why Outlook threads used to render
* their entire history inline with no toggle to collapse it.
*/
/** Marks nodes the iframe stylesheet hides until quotes are toggled on. */
export const QUOTED_CLASS = 'rb-quoted'
const WRAPPER_QUOTE_SELECTOR = [
'.gmail_quote',
'.gmail_attr',
'blockquote[type="cite"]',
'.yahoo_quoted',
'.protonmail_quote',
].join(', ')
const BOUNDARY_QUOTE_SELECTOR = [
'#divRplyFwdMsg', // Outlook on the web / "new Outlook"
'#appendonsend', // Outlook, an empty marker sitting before the quoted chain
'.moz-cite-prefix', // Thunderbird
].join(', ')
// Outlook desktop draws the divider as an inline-styled <div>. The colour is
// the only stable part of the rule — padding units vary (0in vs 0cm), casing
// varies (#E1E1E1 vs #e1e1e1), and `border:none` may or may not precede it.
const OUTLOOK_DIVIDER_STYLE = /border-top\s*:\s*solid\s*#?e1e1e1/i
const HEADER_SCAN_CHARS = 800
function hasQuotedHeaderBlock(text: string): boolean {
const head = text.slice(0, HEADER_SCAN_CHARS)
return /\bFrom:/i.test(head) && /\b(Sent|Date):/i.test(head) && /\bSubject:/i.test(head)
}
// A bare border-top div is also how plenty of signatures and marketing footers
// draw a horizontal rule, so require the header block before calling it a
// quote boundary. Wrongly hiding someone's signature is worse than wrongly
// showing a quote.
function isOutlookDivider(el: Element): boolean {
if (!OUTLOOK_DIVIDER_STYLE.test(el.getAttribute('style') || '')) return false
return hasQuotedHeaderBlock(el.textContent || '')
}
// Hide the divider plus everything after it. The quoted chain can sit outside
// the divider's parent, so climb to the body marking later siblings at each
// level. Quoted history always terminates the message, so nothing below the
// boundary is content worth keeping.
function markFromBoundary(el: Element, body: Element): void {
el.classList.add(QUOTED_CLASS)
let node: Element | null = el
while (node && node !== body) {
for (let sib = node.nextElementSibling; sib; sib = sib.nextElementSibling) {
sib.classList.add(QUOTED_CLASS)
}
node = node.parentElement
}
}
/** Tags quoted nodes with {@link QUOTED_CLASS}. Returns true if any were found. */
export function markQuotedNodes(doc: Document): boolean {
const body = doc.body
if (!body) return false
let found = false
for (const el of Array.from(body.querySelectorAll(WRAPPER_QUOTE_SELECTOR))) {
el.classList.add(QUOTED_CLASS)
found = true
}
const boundaries = Array.from(body.querySelectorAll(BOUNDARY_QUOTE_SELECTOR))
for (const el of Array.from(body.querySelectorAll('div[style]'))) {
if (isOutlookDivider(el)) boundaries.push(el)
}
for (const el of boundaries) {
// Already inside a quote we hid, or reached by an earlier boundary's sweep.
if (el.closest(`.${QUOTED_CLASS}`)) continue
markFromBoundary(el, body)
found = true
}
return found
}
// True if the HTML — ignoring quoted content — defines its own visual layout
// (real images, tables, explicit backgrounds). Unstyled HTML (Gmail replies,
// Outlook one-liners wrapped in MsoNormal boilerplate, outreach emails with
// only a tracking pixel) gets an iframe that adapts to the app theme; styled
// HTML keeps the white "paper" look so newsletters render as sent.
function isStyledDocument(doc: Document): boolean {
const clone = doc.cloneNode(true) as Document
clone.querySelectorAll(`.${QUOTED_CLASS}`).forEach((n) => n.remove())
if (clone.querySelector('table')) return true
for (const img of Array.from(clone.querySelectorAll('img'))) {
const w = parseInt(img.getAttribute('width') || '0', 10)
const h = parseInt(img.getAttribute('height') || '0', 10)
if (w === 1 && h === 1) continue
const style = img.getAttribute('style') || ''
if (/display\s*:\s*none/i.test(style)) continue
if (/visibility\s*:\s*hidden/i.test(style)) continue
return true
}
const visible = clone.body?.innerHTML || ''
if (/bgcolor\s*=/i.test(visible)) return true
if (/background-(color|image)\s*:/i.test(visible)) return true
return false
}
export interface PreparedEmail {
/** Body HTML with quoted nodes tagged, ready to embed in the iframe. */
html: string
hasQuote: boolean
styled: boolean
}
// A forward is quoted content all the way down: hiding it leaves a blank body
// behind a "•••" nobody knows to press. When nothing survives, show everything
// and drop the toggle. Real replies clear this easily — the shortest in a
// 558-message sample left 13 visible characters, forwards left zero.
function unmarkIfNothingRemains(doc: Document): boolean {
const clone = doc.cloneNode(true) as Document
clone.querySelectorAll(`.${QUOTED_CLASS}`).forEach((n) => n.remove())
const visible = (clone.body?.textContent || '').replace(/[\s\u00a0]+/g, '')
if (visible.length > 0) return true
doc.querySelectorAll(`.${QUOTED_CLASS}`).forEach((n) => n.classList.remove(QUOTED_CLASS))
return false
}
/**
* Parse once, tag the quotes, and decide the styling so the iframe hides
* quoted history on its first paint and reports the right height immediately.
*/
export function prepareEmailHtml(rawHtml: string): PreparedEmail {
const doc = new DOMParser().parseFromString(rawHtml, 'text/html')
const hasQuote = markQuotedNodes(doc) && unmarkIfNothingRemains(doc)
const styled = isStyledDocument(doc)
// Emails ship <style> blocks that the parser hoists into <head>. Only the
// body gets re-embedded downstream, so carry the head along with it — a
// <style> is just as valid inside <body>.
const html = (doc.head?.innerHTML || '') + (doc.body?.innerHTML || '')
return { html, hasQuote, styled }
}
export function isReplyQuoteBoundary(lines: string[], index: number): boolean {
const line = lines[index]?.trim() || ''
if (/^On\b.+\bwrote:\s*$/i.test(line)) return true
if (/^-{2,}\s*(Original Message|Forwarded message)\s*-{2,}$/i.test(line)) return true
if (/^From:\s+\S/i.test(line)) {
const next = lines.slice(index + 1, index + 6).map((value) => value.trim())
return next.some((value) => /^(Sent|Date):\s+\S/i.test(value))
&& next.some((value) => /^To:\s+\S/i.test(value))
&& next.some((value) => /^Subject:\s+\S/i.test(value))
}
return false
}
function quoteBoundaryIndex(lines: string[]): number {
return lines.findIndex((line, index) => {
if (isReplyQuoteBoundary(lines, index)) return true
return index > 0
&& line.trim().startsWith('>')
&& (lines[index - 1]?.trim() === '' || lines[index - 1]!.trim().startsWith('>'))
})
}
function normalizeNewlines(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
/**
* Split a plain-text body into the new content and the quoted tail. Recognises
* every boundary {@link isReplyQuoteBoundary} does, not just "On … wrote:".
*/
export function splitPlainTextQuote(text: string): { visible: string; quoted: string | null } {
const lines = normalizeNewlines(text).split('\n')
const boundary = quoteBoundaryIndex(lines)
if (boundary < 0) return { visible: text, quoted: null }
const quoted = lines.slice(boundary).join('\n')
if (!quoted.trim()) return { visible: text, quoted: null }
return { visible: lines.slice(0, boundary).join('\n').trimEnd(), quoted }
}
/** Drop the quoted tail entirely — used when seeding the composer from a draft. */
export function stripQuotedReplyText(text: string): string {
const lines = normalizeNewlines(text).split('\n')
const boundary = quoteBoundaryIndex(lines)
const visible = boundary >= 0 ? lines.slice(0, boundary) : lines
return visible.join('\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
}