import { isValidEmail } from "@/lib/validation"; const HTML_ESCAPE_MAP = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", } as const; function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => HTML_ESCAPE_MAP[char as keyof typeof HTML_ESCAPE_MAP] ); } export function plainTextToComposerBody(text: string): string { if (!text) return ""; return text .replace(/\r\n?/g, "\n") .split(/\n{2,}/) .map((paragraph) => `

${escapeHtml(paragraph).replace(/\n/g, "
")}

`) .join(""); } // Transparent 1x1 GIF used as a stand-in src while the real inline image is // being fetched from JMAP. Browsers cannot render `cid:` URLs directly, so // without this swap the editor would show a broken-image icon (issue #163). export const INLINE_IMAGE_PLACEHOLDER = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; /** * Rewrites `` references into `` * so TipTap can render the editor (the original cid: URL would 404) while still * carrying the cid through edits. The placeholder is swapped to the actual * image data once the corresponding inline blob has been fetched. */ export function rewriteCidImagesForEditor(html: string): string { if (!html || html.indexOf("cid:") === -1) return html; const doc = new DOMParser().parseFromString(`${html}`, "text/html"); let touched = false; doc.querySelectorAll("img").forEach((img) => { const src = img.getAttribute("src") || ""; if (!/^cid:/i.test(src)) return; const cid = src.slice(4); if (!cid) return; if (!img.getAttribute("data-cid")) { img.setAttribute("data-cid", cid); } img.setAttribute("src", INLINE_IMAGE_PLACEHOLDER); touched = true; }); return touched ? doc.body.innerHTML : html; } /** A composer recipient. Display name is optional; email is required. */ export type Recipient = { name?: string; email: string }; /** * Splits a recipient string into individual entries on any character in * `separators`, treating those characters as literal when they sit inside a * quoted display name (`"Doo, John" `) or angle brackets * (``). Trims each part and drops empties. * * Defaults to comma-only, the (de)serialization boundary used by the composer * state and mailto handling. Pasted lists pass a wider set (see * {@link splitPasteEntries}) because they also use `;` and line breaks. */ export function splitRecipients(value: string, separators = ','): string[] { const result: string[] = []; let current = ''; let inQuotes = false; let inAngle = false; for (const ch of value) { if (ch === '"') { inQuotes = !inQuotes; current += ch; } else if (ch === '<' && !inQuotes) { inAngle = true; current += ch; } else if (ch === '>' && !inQuotes) { inAngle = false; current += ch; } else if (separators.includes(ch) && !inQuotes && !inAngle) { const trimmed = current.trim(); if (trimmed) result.push(trimmed); current = ''; } else { current += ch; } } const trimmed = current.trim(); if (trimmed) result.push(trimmed); return result; } // Display names containing any of these must be wrapped in a quoted-string so // they survive comma-splitting at the serialization boundary and round-trip. const NAME_NEEDS_QUOTING = /[,<>"@;:]/; /** * Formats a recipient as a string. Bare email when there's no distinct name; * otherwise `Name `, RFC 5322 quoting the name when it contains a comma * or other special character. */ export function formatRecipient(name: string | undefined, email: string): string { const trimmedName = name?.trim(); if (!trimmedName || trimmedName === email) return email; const quoted = NAME_NEEDS_QUOTING.test(trimmedName) ? `"${trimmedName.replace(/(["\\])/g, '\\$1')}"` : trimmedName; return `${quoted} <${email}>`; } /** Strips a surrounding quoted-string (and its escapes) from a display name. */ function unquoteName(name: string): string { const trimmed = name.trim(); if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { return trimmed.slice(1, -1).replace(/\\(["\\])/g, '$1'); } return trimmed; } /** * Parses a single recipient string (`Name `, `"Quoted, Name" `, * or bare `email`) into a {@link Recipient}. The display name is unquoted. */ export function parseRecipient(s: string): Recipient { const trimmed = s.trim(); const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); if (angleMatch) { return { name: unquoteName(angleMatch[1]), email: angleMatch[2].trim() }; } return { email: trimmed }; } /** Parses a serialized comma-separated recipient string into an array. */ export function parseRecipientList(value: string): Recipient[] { return splitRecipients(value).map(parseRecipient); } /** Serializes a recipient array into a comma-separated string. */ export function formatRecipientList(recipients: Recipient[]): string { return recipients.map((r) => formatRecipient(r.name, r.email)).join(', '); } /** * Top-level split of a pasted block into recipient entries on commas, * semicolons and newlines (separators inside a quoted name or angle brackets * stay literal). Broader than the comma-only default of {@link splitRecipients} * because pasted lists also use `;` and line breaks as separators. */ function splitPasteEntries(value: string): string[] { return splitRecipients(value, ',;\n\r'); } /** * Splits pasted text into recipient candidates and partitions them: valid email * addresses become `Recipient`s (deduped case-insensitively against * `existingEmails` and within the paste), and everything else is returned as * `invalid` for the caller to drop back into the input field. * * Handles both structured and bare lists, preserving display names: * - `"Name "` (the whole recipient quoted), `Name `, and * `"Doe, John" ` entries are kept intact with their display name. * - Bare-address dumps (`a@x.com b@y.com`, spreadsheet columns, comma/space/ * semicolon/newline separated) split into one chip per address. * - A token wrapped in angle brackets (``) is unwrapped before * validating, so an `a ` fragment still yields the address. */ export function splitPastedRecipients( text: string, existingEmails: string[] = [], ): { valid: Recipient[]; invalid: string[] } { const seen = new Set(existingEmails.map((e) => e.toLowerCase())); const valid: Recipient[] = []; const invalid: string[] = []; // Adds a recipient if its address is valid and unseen. Returns true when the // entry is fully handled (valid or a known duplicate) so the caller can stop. const tryAdd = (r: Recipient): boolean => { if (!isValidEmail(r.email)) return false; const key = r.email.toLowerCase(); if (!seen.has(key)) { seen.add(key); valid.push(r.name ? { name: r.name, email: r.email } : { email: r.email }); } return true; }; // Split on commas, semicolons and newlines in a quote/angle-aware way so a // `"Doe, John" ` or fully-quoted `"Name "` entry stays a // single recipient (separators inside the name or the address are literal). for (const entry of splitPasteEntries(text)) { // 1. Structured: `Name `, a bare address, or the whole // `Name ` wrapped in quotes (unwrap once and retry). if (tryAdd(parseRecipient(entry))) continue; const unwrapped = unquoteName(entry); if (unwrapped !== entry && tryAdd(parseRecipient(unwrapped))) continue; // 2. Fallback: a bare-address run (`a@x.com b@y.com`) or a // `John Doe ` fragment where only the is valid. // Whitespace/semicolon-tokenize; leftover tokens stay behind. for (const token of entry.split(/[\s;]+/).map((t) => t.trim()).filter(Boolean)) { if (!tryAdd({ email: token.replace(/^<|>$/g, '') })) invalid.push(token); } } return { valid, invalid }; } /** * Replaces the placeholder src on `` elements with the * resolved data URL once the inline blob has been fetched. Leaves images * whose src has been edited away from the placeholder/cid alone. */ export function replaceInlineImagePlaceholders( html: string, cidToDataUrl: Map ): string { if (!html || cidToDataUrl.size === 0) return html; if (html.indexOf("data-cid") === -1) return html; const doc = new DOMParser().parseFromString(`${html}`, "text/html"); let changed = false; doc.querySelectorAll("img[data-cid]").forEach((img) => { const cid = img.getAttribute("data-cid"); if (!cid) return; const dataUrl = cidToDataUrl.get(cid); if (!dataUrl) return; const currentSrc = img.getAttribute("src") || ""; if (currentSrc !== INLINE_IMAGE_PLACEHOLDER && !/^cid:/i.test(currentSrc)) return; img.setAttribute("src", dataUrl); changed = true; }); return changed ? doc.body.innerHTML : html; }