ultisuite-client/lib/mail-quoted-content.ts
R3D347HR4Y 8a02c10ba3 Add environment configuration and update email view components
- Created a .cursorignore file to manage local environment files.
- Updated .env.example to reflect changes in the public app URL.
- Modified the gmail workspace configuration to include the drive-suite path.
- Enhanced email view components to support attachment handling and fallback for plain text bodies.
- Improved user experience by updating attachment display logic and integrating inline attachment support.
2026-06-04 00:12:43 +02:00

220 lines
6.9 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
/** 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(/^<pre[^>]*>([\s\S]*)<\/pre>$/i)
if (!preMatch) return { mainHtml: html, quotedHtml: null }
const text = preMatch[1]!
const decoded = normalizeCollapsedPlainQuotes(
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) {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
const wrap = (body: string) =>
`<pre style="white-space:pre-wrap;font-family:inherit;margin:0;">${body}</pre>`
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, "&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 (/^<pre\b/i.test(trimmed) || /<pre\b/i.test(trimmed)) {
return splitPlainPreBody(trimmed)
}
if (typeof DOMParser === "undefined") {
return { mainHtml: html, quotedHtml: null }
}
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 splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: string | null } {
return splitPlainPreBody(html)
}
export function hasQuotedContent(html: string): boolean {
return splitQuotedHtml(html).quotedHtml !== null
}