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

171 lines
5.1 KiB
TypeScript

/**
* Client-side repair for messages stored with raw MIME in body_text/body_html
* (before backend decode fix). Mirrors imap.RepairStoredBodies heuristics.
*/
import {
stripHiddenEmailHtml,
stripInvisibleTextRuns,
} from "@/lib/strip-hidden-email-html"
function looksLikeRawMime(s: string): boolean {
if (!s.includes("Content-Type:")) return false
return (
s.includes("Content-Transfer-Encoding:") ||
(s.includes("--") && s.toLowerCase().includes("multipart"))
)
}
function decodeBase64Part(encoded: string): string {
const clean = encoded.replace(/[\r\n\t ]/g, "")
try {
if (typeof atob !== "undefined") {
const bytes = Uint8Array.from(atob(clean), (c) => c.charCodeAt(0))
return new TextDecoder("utf-8").decode(bytes)
}
} catch {
return ""
}
return ""
}
function parseEmbeddedMime(raw: string): { text: string; html: string } | null {
if (!looksLikeRawMime(raw)) return null
const boundaryMatch = raw.match(/boundary\s*=\s*"?([^";\s]+)"?/i)
const boundary =
boundaryMatch?.[1] ??
(() => {
for (const line of raw.split(/\r?\n/)) {
const t = line.trim()
if (t.startsWith("--") && !t.endsWith("--") && t.length > 2) {
return t.slice(2).trim()
}
}
return ""
})()
if (!boundary) return null
const parts = raw.split(new RegExp(`--${escapeRegExp(boundary)}(?:--)?\\s*\\r?\\n`))
let text = ""
let html = ""
for (const part of parts) {
const trimmed = part.trim()
if (!trimmed || !trimmed.includes("Content-Type:")) continue
const headerEnd = trimmed.search(/\r?\n\r?\n/)
if (headerEnd < 0) continue
const headers = trimmed.slice(0, headerEnd)
const body = trimmed.slice(headerEnd).replace(/^[\r\n]+/, "")
const typeMatch = headers.match(/Content-Type:\s*([^\r\n;]+)/i)
const mediaType = typeMatch?.[1]?.trim().toLowerCase() ?? ""
const encMatch = headers.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i)
const encoding = encMatch?.[1]?.trim().toLowerCase() ?? ""
let decoded = body.trim()
if (encoding === "base64") {
decoded = decodeBase64Part(decoded)
}
if (mediaType === "text/plain" && !text) text = decoded
if (mediaType === "text/html" && !html) html = decoded
}
if (!text && !html) return null
if (looksLikeRawMime(text) || looksLikeRawMime(html)) return null
return { text, html }
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function looksLikeBareBase64(s: string): boolean {
const clean = s.replace(/[\r\n\t ]/g, "")
if (clean.length < 24 || clean.length % 4 !== 0) return false
return /^[A-Za-z0-9+/]+=*$/.test(clean)
}
function looksLikeQuotedPrintable(s: string): boolean {
if (s.includes("=\r\n") || s.includes("=\n")) return true
if (s.includes("=3D") || s.includes("=C3=") || s.includes("=E2=")) return true
return (s.match(/=[0-9A-Fa-f]{2}/g)?.length ?? 0) >= 3
}
function decodeQuotedPrintableIfNeeded(s: string): string {
if (!looksLikeQuotedPrintable(s)) return s
try {
const bytes: number[] = []
const normalized = s.replace(/\r\n/g, "\n")
for (let i = 0; i < normalized.length; ) {
const ch = normalized[i]
if (ch === "=") {
if (normalized[i + 1] === "\n") {
i += 2
continue
}
const hex = normalized.slice(i + 1, i + 3)
if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
bytes.push(parseInt(hex, 16))
i += 3
continue
}
}
bytes.push(ch.charCodeAt(0))
i += 1
}
return new TextDecoder("utf-8").decode(new Uint8Array(bytes))
} catch {
return s
}
}
function decodeBareBase64IfNeeded(s: string): string {
if (!looksLikeBareBase64(s)) return s
const decoded = decodeBase64Part(s)
if (!decoded || decoded === s) return s
return decoded
}
export function repairMimeBodies(
bodyText?: string,
bodyHtml?: string
): { bodyText?: string; bodyHtml?: string } {
let text = bodyText?.trim() ?? ""
let html = bodyHtml?.trim() ?? ""
text = decodeQuotedPrintableIfNeeded(text)
html = decodeQuotedPrintableIfNeeded(html)
text = decodeBareBase64IfNeeded(text)
html = decodeBareBase64IfNeeded(html)
html = stripHiddenEmailHtml(html)
text = stripInvisibleTextRuns(text)
if (!looksLikeRawMime(text) && !looksLikeRawMime(html)) {
return { bodyText: text || bodyText, bodyHtml: html || bodyHtml }
}
const parsed = parseEmbeddedMime(text || html)
if (!parsed) return { bodyText: text || bodyText, bodyHtml: html || bodyHtml }
return {
bodyText: parsed.text || text || bodyText,
bodyHtml: parsed.html || html || bodyHtml,
}
}
/** List/search preview stored as undecoded base64. */
export function repairSnippet(snippet?: string): string | undefined {
if (!snippet?.trim()) return snippet
const trimmed = snippet.trim()
const qp = decodeQuotedPrintableIfNeeded(trimmed)
const decoded = decodeBareBase64IfNeeded(qp)
if (decoded !== trimmed) {
return stripInvisibleTextRuns(
decoded.length > 200 ? decoded.slice(0, 200) : decoded
)
}
return stripInvisibleTextRuns(snippet)
}