/** * 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) }