mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
1d16d16f15
commit
992c1883a7
3 changed files with 345 additions and 73 deletions
|
|
@ -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
|
||||
|
|
|
|||
138
apps/x/apps/renderer/src/lib/email-quotes.test.ts
Normal file
138
apps/x/apps/renderer/src/lib/email-quotes.test.ts
Normal 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 <c@firm.com><br>
|
||||
<b>Sent:</b> Tuesday, July 7, 2026 9:14 AM<br>
|
||||
<b>To:</b> Me <me@co.com><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.')
|
||||
})
|
||||
})
|
||||
198
apps/x/apps/renderer/src/lib/email-quotes.ts
Normal file
198
apps/x/apps/renderer/src/lib/email-quotes.ts
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue