diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index f1b9ff17..bd720692 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -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; } ${html}` } @@ -436,24 +371,25 @@ function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; t const saveTimerRef = useRef | null>(null) const lastSavedHeightRef = useRef(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 diff --git a/apps/x/apps/renderer/src/lib/email-quotes.test.ts b/apps/x/apps/renderer/src/lib/email-quotes.test.ts new file mode 100644 index 00000000..0caf540b --- /dev/null +++ b/apps/x/apps/renderer/src/lib/email-quotes.test.ts @@ -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 = ` +
+

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.

` + +describe('prepareEmailHtml — quote detection', () => { + it('hides an Outlook quoted chain, including the siblings after the divider', () => { + const { html, hasQuote } = prepareEmailHtml( + `

Agreed, see below.

${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( + `

Sounds good.

-- Jane, Partner

${OUTLOOK_QUOTE}
`, + ) + 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( + `
Short reply.
From: C
Older content.
`, + ) + expect(hasQuote).toBe(true) + expect(visible(html)).toBe('Short reply.') + }) + + it('still hides Gmail wrapper quotes', () => { + const { html, hasQuote } = prepareEmailHtml( + `
Thanks!
On Tue, X wrote:
old
`, + ) + 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 = `

Hi there.

Jane Doe | Acme | jane@acme.com

` + 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( + `
---------- Forwarded message ---------
The actual content.
`, + ) + expect(prepared.hasQuote).toBe(false) + expect(visible(prepared.html)).toContain('The actual content.') + }) + + it('reports no quote for a plain message', () => { + expect(prepareEmailHtml('

Just a note.

').hasQuote).toBe(false) + }) + + it('keeps a

Body

') + 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( + `

ok

old
`, + ) + expect(styled).toBe(false) + }) +}) + +describe('splitPlainTextQuote', () => { + it('splits on an Outlook From:/Sent:/To:/Subject: header block', () => { + const body = [ + 'Agreed.', + '', + 'From: Counsel ', + 'Sent: Tuesday, July 7, 2026 9:14 AM', + 'To: Me ', + '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 \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.') + }) +}) diff --git a/apps/x/apps/renderer/src/lib/email-quotes.ts b/apps/x/apps/renderer/src/lib/email-quotes.ts new file mode 100644 index 00000000..37fdd553 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/email-quotes.ts @@ -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
. 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