/** Detect and split quoted reply tails from message HTML (thread view). */ const WROTE_LINE = /(^|\n)\s*(Le\s.+a\sécrit\s*:|On\s.+wrote:|Am\s.+schrieb|El\s.+escribió|Il\s.+ha\s+scritto|.+a écrit\s*:)/i /** Inline reply header after whitespace collapse (no leading newline). */ const WROTE_INLINE = /(?:^|[\s.])(Le\s.+?a\s+écrit\s*:|On\s+.+?\bwrote:|Am\s+.+?\bschrieb|El\s+.+?\bescribió|Il\s+.+?\bha\s+scritto|.+?\ba\s+écrit\s*:)/i const QUOTE_SELECTOR = [ ".gmail_quote", ".gmail_extra", ".protonmail_quote", ".yahoo_quoted", ".moz-cite-prefix", "#divRplyFwdMsg", 'blockquote[type="cite"]', "blockquote.gmail_quote", ].join(",") function wroteLineText(text: string | null | undefined): boolean { if (!text?.trim()) return false return WROTE_LINE.test(text.trim()) } function isLikelyReplyBlockquote(el: Element): boolean { if (el.tagName !== "BLOCKQUOTE") return false const type = el.getAttribute("type")?.toLowerCase() if (type === "cite") return true const cls = el.className?.toString().toLowerCase() ?? "" if (cls.includes("gmail_quote") || cls.includes("protonmail")) return true const prev = el.previousElementSibling if (prev && wroteLineText(prev.textContent)) return true return false } function findQuoteElement(root: Element): Element | null { for (const sel of QUOTE_SELECTOR.split(",")) { const hit = root.querySelector(sel.trim()) if (hit) return hit } for (const bq of root.querySelectorAll("blockquote")) { if (isLikelyReplyBlockquote(bq)) return bq } for (const child of root.children) { if (wroteLineText(child.textContent) && child.textContent!.length < 500) { const next = child.nextElementSibling if (next && (next.tagName === "BLOCKQUOTE" || next.matches(QUOTE_SELECTOR))) { return child } } } return null } function quoteSplitStart(quoteEl: Element): Element { const prev = quoteEl.previousElementSibling if (prev && (wroteLineText(prev.textContent) || prev.classList.contains("gmail_attr"))) { return prev } return quoteEl } function fragmentHtml(doc: Document, frag: DocumentFragment): string { const wrap = doc.createElement("div") wrap.appendChild(frag) return wrap.innerHTML.trim() } /** Split root contents at quote node (works when quote is nested in a single wrapper div). */ function splitAtQuote(root: Element, quoteEl: Element): { mainHtml: string quotedHtml: string | null } { const doc = root.ownerDocument if (!doc || !root.firstChild) { return { mainHtml: root.innerHTML, quotedHtml: null } } const start = quoteSplitStart(quoteEl) try { const range = doc.createRange() range.setStartBefore(root.firstChild) range.setEndBefore(start) const mainFrag = range.cloneContents() range.setStartBefore(start) const last = root.lastChild if (!last) return { mainHtml: root.innerHTML, quotedHtml: null } range.setEndAfter(last) const quotedFrag = range.cloneContents() const mainHtml = fragmentHtml(doc, mainFrag) const quotedHtml = fragmentHtml(doc, quotedFrag) if (!quotedHtml) return { mainHtml: root.innerHTML, quotedHtml: null } if (!mainHtml) return { mainHtml: root.innerHTML, quotedHtml: null } return { mainHtml, quotedHtml } } catch { return { mainHtml: root.innerHTML, quotedHtml: null } } } function splitHtmlRoot(root: Element): { mainHtml: string; quotedHtml: string | null } { const quoteHit = findQuoteElement(root) if (!quoteHit) { return { mainHtml: root.innerHTML, quotedHtml: null } } return splitAtQuote(root, quoteHit) } /** Restore line-based quotes after preview repair collapsed newlines to spaces. */ function normalizeCollapsedPlainQuotes(text: string): string { let s = text.replace(/ > /g, "\n> ") const wroteIdx = findInlineWroteIndex(s) if (wroteIdx > 0) { const before = s.slice(0, wroteIdx).trimEnd() const after = s.slice(wroteIdx).trimStart() if (before && after) s = `${before}\n\n${after}` } return s } function findInlineWroteIndex(text: string): number { const m = WROTE_INLINE.exec(text) if (!m || m.index === undefined) return -1 let idx = m.index const lead = m[0]![0] if (lead && /\s/.test(lead)) idx += 1 return idx } function splitPlainPreBody(html: string): { mainHtml: string; quotedHtml: string | null } { const preMatch = html.match(/^]*>([\s\S]*)<\/pre>$/i) if (!preMatch) return { mainHtml: html, quotedHtml: null } const text = preMatch[1]! const decoded = normalizeCollapsedPlainQuotes( text .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&") ) const lines = decoded.split("\n") let splitAt = -1 for (let i = 0; i < lines.length; i++) { const line = lines[i]! if (/^>/.test(line.trim())) { splitAt = i break } if (WROTE_LINE.test(line)) { splitAt = i break } } if (splitAt < 0) { const inlineIdx = findInlineWroteIndex(decoded) if (inlineIdx < 0) return { mainHtml: html, quotedHtml: null } const main = decoded.slice(0, inlineIdx).trimEnd() const quoted = decoded.slice(inlineIdx).trimStart() if (!quoted) return { mainHtml: html, quotedHtml: null } const escape = (s: string) => s.replace(/&/g, "&").replace(//g, ">") const wrap = (body: string) => `
${body}
` return { mainHtml: wrap(escape(main)), quotedHtml: wrap(escape(quoted)), } } const mainLines = lines.slice(0, splitAt) const quotedLines = lines.slice(splitAt) const escape = (s: string) => s.replace(/&/g, "&").replace(//g, ">") const mainPre = escape(mainLines.join("\n").trimEnd()) const quotedPre = escape(quotedLines.join("\n").trimStart()) if (!quotedPre.trim()) return { mainHtml: html, quotedHtml: null } const wrap = (body: string) => `
${body}
` return { mainHtml: wrap(mainPre), quotedHtml: wrap(quotedPre), } } export function splitQuotedHtml(html: string): { mainHtml: string quotedHtml: string | null } { const trimmed = html.trim() if (!trimmed) return { mainHtml: html, quotedHtml: null } if (/^${trimmed}`, "text/html" ) const root = doc.getElementById("mail-body-root") if (!root) return { mainHtml: html, quotedHtml: null } return splitHtmlRoot(root) } export function splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: string | null } { return splitPlainPreBody(html) } export function hasQuotedContent(html: string): boolean { return splitQuotedHtml(html).quotedHtml !== null }