import { isValidEmail } from "@/lib/validation"; import { splitMailbox } from "@/lib/rfc5322-mailbox"; import { htmlToPlainText } from "@/lib/html-to-text"; import { emailHooks } from "@/lib/plugin-hooks"; import { Ellipsis, Lock, TriangleAlert } from "@/components/icons"; import type { Email } from "@/lib/jmap/types"; 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] ); } /** * Picks the plain-text and HTML bodies of an original message for seeding a * reply/forward quote. * * Per RFC 8621 ยง 4.1.4 a message with only one body variant exposes that * single part in BOTH `textBody` and `htmlBody`. So for an HTML-only message * `textBody[0]` is the raw text/html source, and for a plain-text-only * message `htmlBody[0]` is the text/plain part. Quoting either verbatim * breaks the reply (#649): raw HTML tags end up in a plain-text quote, and * plain text rendered as HTML collapses all newlines. Route by each part's * actual MIME type instead: HTML listed under textBody is converted to * readable text, and plain text listed under htmlBody is dropped so the * composer's text path (escape +
) renders it. */ export function getQuoteBodies( email: Pick ): { body: string; htmlBody?: string } { const textPart = email.textBody?.[0]; const htmlPart = email.htmlBody?.[0]; const textValue = textPart ? email.bodyValues?.[textPart.partId]?.value : undefined; const htmlValue = htmlPart ? email.bodyValues?.[htmlPart.partId]?.value : undefined; const textPartIsHtml = textPart?.type?.toLowerCase() === "text/html"; // A missing type is treated as HTML, matching the viewer's rendering path. const htmlPartIsHtml = !htmlPart?.type || htmlPart.type.toLowerCase() === "text/html"; const body = textValue ? (textPartIsHtml ? htmlToPlainText(textValue, { paragraphSpacing: true }) : textValue) : (email.preview || ""); return { body, htmlBody: htmlPartIsHtml ? htmlValue || undefined : undefined, }; } 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"; /** * Sniffs the real image MIME type from a blob's leading magic bytes, returning * null when the bytes aren't a recognizable image. * * Some clients (notably Foxmail 7.2) embed inline images as * `Content-Type: application/octet-stream` with no `Content-Disposition: * inline` - the cid reference in the HTML is the only signal they're meant to * render in-body (#543). The declared type can't be trusted in that case, so * the composer looks at the bytes themselves to build a usable data URL and to * re-attach the part with a correct `image/*` type when the reply is sent. */ export function sniffImageMime(bytes: Uint8Array): string | null { if (!bytes || bytes.length < 4) return null; // PNG: 89 50 4E 47 if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) { return "image/png"; } // JPEG: FF D8 FF if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { return "image/jpeg"; } // GIF: 47 49 46 38 ("GIF8") if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) { return "image/gif"; } // WebP: "RIFF" .... "WEBP" if ( bytes.length >= 12 && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 ) { return "image/webp"; } // BMP: 42 4D ("BM") if (bytes[0] === 0x42 && bytes[1] === 0x4d) { return "image/bmp"; } return null; } /** * 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; } /** * Collects the cids of every `` in a composer body, i.e. * exactly the inline images the quoted original actually renders. * * Used to decide which cid attachments are worth fetching (and, on forward, * which ones are already embedded in the body and so must not be listed as * separate attachments too). Matching on the body rather than on the part's * declared type/disposition is what makes Foxmail-style * `application/octet-stream` inline images work. */ export function collectInlineImageCids(html: string): Set { const cids = new Set(); if (!html || html.indexOf("data-cid") === -1) return cids; const doc = new DOMParser().parseFromString(`${html}`, "text/html"); doc.querySelectorAll("img[data-cid]").forEach((img) => { const cid = img.getAttribute("data-cid"); if (cid) cids.add(cid); }); return cids; } /** * Reduce a composer body to just the user-authored text for the attachment * reminder's keyword scan, dropping the quoted original of a reply/forward. * * Scanning the whole body triggered false positives whenever the quoted message * mentioned an attachment - common, since the original often did carry one, and * the default keyword list is broad and multilingual (#570). We strip: * - HTML mode: the QuotedHtml island ([data-quoted-html]) and any
* (the wrapper used when the original had no HTML part), then convert to text. * - Plain-text mode: lines prefixed with ">" (the reply quote). * - Both modes: everything from the "Forwarded message" separator onward, which * also removes the forwarded From/Date/Subject header lines and the bare * forwarded original (which carries no blockquote/island wrapper). * * `forwardedSeparator` is the localized quote_header.forwarded_separator string; * pass it so the forward cut works in the active locale. */ export function extractUserAuthoredText( body: string, options: { plainTextMode: boolean; forwardedSeparator?: string } ): string { const { plainTextMode, forwardedSeparator } = options; let text: string; if (plainTextMode) { text = body .split("\n") .filter((line) => !/^\s*>/.test(line)) .join("\n"); } else { const doc = new DOMParser().parseFromString(`${body}`, "text/html"); doc .querySelectorAll("[data-quoted-html], blockquote") .forEach((el) => el.remove()); text = htmlToPlainText(doc.body.innerHTML, { paragraphSpacing: true }); } // Cut everything from the forwarded-message separator onward. htmlToPlainText // collapses the separator's internal whitespace, so match with a // whitespace-flexible, regex-escaped pattern rather than an exact string. const trimmedSeparator = forwardedSeparator?.trim(); if (trimmedSeparator) { const pattern = trimmedSeparator .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") .replace(/\s+/g, "\\s+"); const match = text.match(new RegExp(pattern)); if (match && match.index !== undefined) { text = text.slice(0, match.index); } } return text; } /** * Used for hook to let plugins enrich recipient chips with colors and icons. * The icon is a key into ICON_MAP, which maps to an icon component. */ export const ICON_MAP = { 'lock': Lock, 'triangle-alert': TriangleAlert, 'ellipsis': Ellipsis, }; type IconName = keyof typeof ICON_MAP; /** * A composer recipient. Display name is optional; email is required - except * for contact-group chips, which carry their already-resolved members and an * empty email. Group chips are expanded into their members when the message * is sent or saved as a draft (see {@link expandRecipients}). */ export type Recipient = { name?: string; email: string; group?: { members: Array<{ name?: string; email: string }> }; extra?: { color?: "success" | "destructive" | "warning"; // optional color for display purposes. May be populated by plugins via the onRecipientChipsChange hook. icon?: IconName; // optional icon for display purposes. May be populated by plugins via the onRecipientChipsChange hook. enriched?: boolean; // optional flag to indicate if the recipient has been enriched by plugins via the onRecipientChipsChange hook. }; }; /** Enriches recipient chips with colors and icons. */ export async function enrichChipsWithColorsAndIcons(chips: Recipient[]): Promise { return await emailHooks.onRecipientChipsChange.transform(chips); }; /** * True when the angle run that is open at `from` closes before the next one * opens. `from` is the index of the character under test (a separator, or the * opening `<` itself); the scan starts after it either way. A `>` inside a * quoted display name does not close anything, and a `<` with no `>` of its own * (or whose `>` sits past a later `<`) can never be an address delimiter, so * separators after it must stay separators. */ function angleRunCloses(value: string, from: number): boolean { let inQuotes = false; for (let i = from + 1; i < value.length; i++) { const ch = value[i]; if (inQuotes && ch === '\\') i++; else if (ch === '"') inQuotes = !inQuotes; else if (inQuotes) continue; else if (ch === '>') return true; else if (ch === '<') return false; } return false; } /** * 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; let inGroup = false; for (let i = 0; i < value.length; i++) { const ch = value[i]; if (inQuotes && ch === '\\' && i + 1 < value.length) { // A quoted-pair: formatRecipient writes a `"` in a display name as // `\"`. Taking it as the closing quote let a sender's name like // `Support", ceo@corp.example, "x` split into an extra recipient. current += ch + value[i + 1]; i++; } else 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) && inAngle && !inQuotes && !inGroup && !angleRunCloses(value, i)) { // An unclosed `<` would otherwise swallow every later separator, folding // the whole list into one entry that is not an address at all. Inside a // group the separators belong to the group, so `inGroup` still wins. inAngle = false; const trimmed = current.trim(); if (trimmed) result.push(trimmed); current = ''; } else if (ch === ':' && !inQuotes && !inAngle) { // RFC 5322 group syntax ("Team: a@x, b@y;") - keep the whole group, // separators inside it included, as a single entry. A colon inside a // display name is always quoted (see NAME_NEEDS_QUOTING), so a bare // colon reliably opens a group. inGroup = true; current += ch; } else if (ch === ';' && inGroup && !inQuotes && !inAngle) { inGroup = false; current += ch; } else if (separators.includes(ch) && !inQuotes && !inAngle && !inGroup) { 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; } /** Index of the first colon outside quotes/angle brackets, or -1. */ function findTopLevelColon(value: string): number { let inQuotes = false; let inAngle = false; for (let i = 0; i < value.length; i++) { const ch = value[i]; if (inQuotes && ch === '\\') i++; else if (ch === '"') inQuotes = !inQuotes; else if (ch === '<' && !inQuotes) inAngle = true; else if (ch === '>' && !inQuotes) inAngle = false; else if (ch === ':' && !inQuotes && !inAngle) return i; } return -1; } /** * Parses a single recipient string (`Name `, `"Quoted, Name" `, * or bare `email`) into a {@link Recipient}. The display name is unquoted. * RFC 5322 group syntax (`Team: a@x, b@y;`) parses into a group chip - it is * how contact groups round-trip through the composer's string boundaries. */ export function parseRecipient(s: string): Recipient { const trimmed = s.trim(); if (trimmed.endsWith(';')) { const colon = findTopLevelColon(trimmed); if (colon !== -1) { const members = splitRecipients(trimmed.slice(colon + 1, -1)) .map(parseRecipient) .filter((m) => m.email && !m.group); // Only accept the group form when at least one member is an address - // typed garbage like "Subject: hello;" stays a plain (invalid) recipient. if (members.some((m) => isValidEmail(m.email))) { return { name: unquoteName(trimmed.slice(0, colon)), email: '', group: { members } }; } } } const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); if (angleMatch) { return { name: unquoteName(angleMatch[1]), email: angleMatch[2].trim() }; } return { email: trimmed }; } /** * The autocomplete suggestion Enter or Tab takes when the user has not moved * the highlight with the arrow keys. A complete address the user typed wins: * its own suggestion when one matches (so the display name comes along), * otherwise -1, meaning "add what was typed". Anything else takes the top * suggestion, so "user" + Enter never becomes a recipient called "user". */ export function defaultSuggestionIndex(input: string, suggestions: Array<{ email: string }>): number { const typed = input.trim(); if (!typed || suggestions.length === 0) return -1; const { email } = parseRecipient(typed); if (isValidEmail(email)) { const lower = email.toLowerCase(); return suggestions.findIndex((s) => s.email.toLowerCase() === lower); } return 0; } /** Parses a serialized comma-separated recipient string into an array. */ export function parseRecipientList(value: string): Recipient[] { return splitRecipients(value).map(parseRecipient); } /** * Formats a single composer recipient, using RFC 5322 group syntax for * contact-group chips so they survive the composer's string boundaries * (draft data, dirty compare, the contacts-page hand-off). */ export function formatRecipientEntry(r: Recipient): string { if (r.group) { const name = r.name?.trim() || 'Group'; const quoted = NAME_NEEDS_QUOTING.test(name) ? `"${name.replace(/(["\\])/g, '\\$1')}"` : name; const members = r.group.members.map((m) => formatRecipient(m.name, m.email)).join(', '); return `${quoted}: ${members};`; } return formatRecipient(r.name, r.email); } /** Serializes a recipient array into a comma-separated string. */ export function formatRecipientList(recipients: Recipient[]): string { return recipients.map(formatRecipientEntry).join(', '); } /** * Expands contact-group chips into their members for sending and * draft-saving. Deduplicates case-insensitively by address across the whole * list, keeping the first occurrence - an explicitly added individual wins * over the same address arriving again via a group. */ export function expandRecipients(recipients: Recipient[]): Recipient[] { const seen = new Set(); const out: Recipient[] = []; for (const r of recipients) { for (const entry of r.group ? r.group.members : [r]) { const key = entry.email.trim().toLowerCase(); if (!key || seen.has(key)) continue; seen.add(key); out.push({ name: entry.name, email: entry.email }); } } return out; } /** * The entries of `recipients` that are not email addresses, group members * included. Sending any of them puts a malformed mailbox into the header, * which relays reject only after the whole message went through (#795). */ export function invalidRecipientAddresses(recipients: Recipient[]): string[] { return recipients .flatMap((r) => (r.group ? r.group.members : [r])) .filter((r) => !isValidEmail(r.email)) .map((r) => r.email); } /** * 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'); } /** True when a whitespace/semicolon token of `value` is an address in its own right. */ function carriesAddress(value: string): boolean { return value.split(/[\s;]+/).some((t) => isValidEmail(t.trim().replace(/^<|>$/g, ''))); } /** * 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. * - `Name 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. `Name `, so the display name survives the paste. It keeps the // last angle run only, so everything ahead of it would become display // name: skip this step when that prefix carries an address of its own // (`a@x.com ` 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; } export type PendingUploadLike = { uploading?: boolean; error?: boolean; }; export type PendingUploadWaitResult = "completed" | "cancelled" | "failed"; /** * Wait for in-flight attachment uploads to settle before sending. * * Polls `getAttachments` until nothing is `uploading`, checking * `isCancelled` between polls (composer closed / draft discarded). * Resolves: * - "cancelled" - cancellation was signalled while waiting * - "failed" - uploads settled but at least one attachment errored; * the caller must NOT auto-send (the user may not be * looking at the composer to notice the failed chip) * - "completed" - all uploads finished cleanly, safe to proceed */ export async function waitForPendingUploads( getAttachments: () => readonly PendingUploadLike[], isCancelled: () => boolean, pollMs = 150 ): Promise { while (getAttachments().some((att) => att.uploading)) { if (isCancelled()) return "cancelled"; await new Promise((resolve) => setTimeout(resolve, pollMs)); } return getAttachments().some((att) => att.error) ? "failed" : "completed"; }