ultisuite-client/lib/mail-quoted-content.ts
2026-05-25 13:52:40 +02:00

175 lines
5.2 KiB
TypeScript

/** 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(/^<pre[^>]*>([\s\S]*)<\/pre>$/i)
const text = preMatch ? preMatch[1]! : html
const decoded = text
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
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) =>
`<pre style="white-space:pre-wrap;font-family:inherit;margin:0;">${body}</pre>`
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("<pre")) {
const plain = splitPlainTextBody(trimmed)
if (plain.quotedHtml) return plain
}
const doc = new DOMParser().parseFromString(
`<div id="mail-body-root">${trimmed}</div>`,
"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
}