mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #707 from rowboatlabs/fix/outlook-quoted-replies
fix(x): collapse quoted replies in Outlook-authored email
This commit is contained in:
commit
30def374af
3 changed files with 359 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 },
|
||||
|
|
@ -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
|
||||
|
|
|
|||
146
apps/x/apps/renderer/src/lib/email-quotes.test.ts
Normal file
146
apps/x/apps/renderer/src/lib/email-quotes.test.ts
Normal 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 <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('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.')
|
||||
})
|
||||
})
|
||||
200
apps/x/apps/renderer/src/lib/email-quotes.ts
Normal file
200
apps/x/apps/renderer/src/lib/email-quotes.ts
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue