Merge pull request #707 from rowboatlabs/fix/outlook-quoted-replies

fix(x): collapse quoted replies in Outlook-authored email
This commit is contained in:
arkml 2026-07-11 10:52:36 +05:30 committed by GitHub
commit 30def374af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 359 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 },
@ -373,6 +312,10 @@ function buildEmailDocument(
overflow-y: hidden;
word-wrap: break-word;
padding-bottom: 4px;
/* Contain the first child's top margin. Without this it collapses through
<body>, shifting the box down while body.scrollHeight stays short so
the height we hand the iframe cuts the last line off. */
display: flow-root;
}
body > *:last-child { margin-bottom: 0; }
img { max-width: 100%; height: auto; }
@ -384,12 +327,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 +375,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,146 @@
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('preserves <body> attributes, which carry the email background', () => {
// Embedded in the host <body>, the parser merges these onto it. Dropping
// the tag would strip the background off every styled newsletter.
const { html } = prepareEmailHtml('<html><body bgcolor="#f4f4f4" style="margin:0"><p>Hi</p></body></html>')
expect(html).toContain('bgcolor="#f4f4f4"')
expect(html).toContain('margin:0')
})
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,200 @@
/**
* 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)
// Serialize <head> and <body> as *elements*, not just their contents. Most
// emails carry a <body bgcolor=… style=…>, and when this HTML is embedded
// inside the host <body>, the parser merges those attributes onto it — which
// is how backgrounds have always applied. Concatenating innerHTML would drop
// the tag and with it the attributes.
const html = doc.documentElement?.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()
}