/** 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 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) } function splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: string | null } { const preMatch = html.match(/^
]*>([\s\S]*)<\/pre>$/i)
const text = preMatch ? preMatch[1]! : html
const decoded = 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) return { mainHtml: html, quotedHtml: null }
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 (typeof DOMParser === "undefined") {
return { mainHtml: html, quotedHtml: null }
}
if (trimmed.startsWith("${trimmed}`,
"text/html"
)
const root = doc.getElementById("mail-body-root")
if (!root) return { mainHtml: html, quotedHtml: null }
return splitHtmlRoot(root)
}
export function hasQuotedContent(html: string): boolean {
return splitQuotedHtml(html).quotedHtml !== null
}