// Build the initial HTML body for a new compose / reply / forward, mirroring // what the webmail composer (`components/email/email-composer.tsx`) produces. // All values are HTML-escaped before insertion into the document. import { escapeHtml, stripDangerousTags } from './email-html'; import { buildQuoteHeader, type QuoteHeaderLabels } from './quote-header'; import type { TimeFormat } from '../stores/settings-store'; export interface ReplyMeta { from?: { email?: string; name?: string }; to?: Array<{ email?: string; name?: string }>; cc?: Array<{ email?: string; name?: string }>; subject?: string; body?: string; // plain-text body (preferred when htmlBody absent) htmlBody?: string; receivedAt?: string; } export interface QuoteHeaderOptions { timeFormat?: TimeFormat; locale?: string; unknownLabel?: string; labels?: QuoteHeaderLabels; } function escapeForHtmlBody(text: string): string { return escapeHtml(text).replace(/\n/g, '
'); } const BLOCKQUOTE_STYLE = 'margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex'; /** * Marker attribute on the wrapper that holds the quoted original (header + * body). The signature splice uses it to place the signature above the quote, * and the attachment reminder strips it before keyword matching (#570). */ export const QUOTED_BLOCK_ATTR = 'data-quoted-html'; export const QUOTED_BLOCK_START = `
`; /** * Build the seed HTML for a new compose / reply / forward. Returns an empty * paragraph for plain compose (so the contenteditable has a caret target). * * The reply/forward quote header mirrors the webmail default header * (`lib/quote-header.ts`): a reply gets "On , wrote:" above a * blockquoted body; a forward gets a From/Date/Subject block followed by the * original body (not blockquoted). The quoted part is wrapped in a * `data-quoted-html` div. */ export function buildInitialHtml( mode: 'compose' | 'reply' | 'replyAll' | 'forward', reply?: ReplyMeta | null, opts: QuoteHeaderOptions = {}, ): string { if (!reply || mode === 'compose') return '


'; const { timeFormat = '24h', locale, unknownLabel = 'Unknown', labels } = opts; const quotedBody = reply.htmlBody ? stripDangerousTags(reply.htmlBody) : reply.body ? escapeForHtmlBody(reply.body) : ''; if (!quotedBody) return '


'; const header = buildQuoteHeader({ mode, email: { from: reply.from, subject: reply.subject, receivedAt: reply.receivedAt }, timeFormat, locale, unknownLabel, labels, }); const quoted = header.wrapInBlockquote ? `${header.html}
${quotedBody}
` : `${header.html}${quotedBody}`; return `


${QUOTED_BLOCK_START}${quoted}
`; } /** * Strip HTML tags and decode common entities for a plain-text fallback body. * Mirrors the webmail's `htmlToPlainText` (DOMParser-based) since we don't * have DOMParser in the RN runtime. Links keep their target as `text ` * so a pasted link survives the text/plain alternative. */ export function htmlToPlainText(html: string): string { if (!html) return ''; let s = html; // Drop non-content blocks entirely. s = s.replace(/<(script|style|head|title)\b[^>]*>[\s\S]*?<\/\1>/gi, ''); // Links: "text " unless the text already is the URL (or mailto target). s = s.replace(/]*)>([\s\S]*?)<\/a>/gi, (_full, attrs: string, inner: string) => { const hrefMatch = attrs.match(/\shref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i); const href = (hrefMatch?.[2] ?? hrefMatch?.[3] ?? hrefMatch?.[4] ?? '').trim(); const text = inner.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); if (!href) return inner; const normalizedHref = href.replace(/^mailto:/i, ''); if (!text || text === href || text === normalizedHref) return text || href; // Angle brackets are restored after the tag strip below. return `${text} \u0001${href}\u0002`; }); // Treat block-level boundaries and
as newlines before stripping tags. s = s.replace(//gi, '\n'); // Paragraph-level closes get a blank line; list/heading/cell closes a single // newline so they stack tightly. s = s.replace(/<\/(p|div|blockquote|pre)>/gi, '\n\n'); s = s.replace(/<\/(li|h[1-6]|tr)>/gi, '\n'); s = s.replace(/<\/(td|th)>/gi, '\t'); s = s.replace(/]*>/gi, '• '); s = s.replace(/<[^>]+>/g, ''); s = s.replace(/\u0001/g, '<').replace(/\u0002/g, '>'); // Decode the most common entities; leave the rest as-is. s = s .replace(/ /gi, ' ') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/'/gi, "'") .replace(/&/gi, '&'); // Collapse runs of >2 newlines and trim trailing whitespace per line. s = s.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+\n/g, '\n'); return s.trim(); } /** * Remove a balanced `…` block (nesting-aware) for every * occurrence in `html`. Used to drop quoted content before keyword matching. */ function removeBalancedBlocks(html: string, openRe: RegExp, tag: string): string { let out = html; let m: RegExpExecArray | null; const open = new RegExp(openRe.source, 'i'); // eslint-disable-next-line no-cond-assign while ((m = open.exec(out)) !== null) { const start = m.index; let depth = 0; let i = start; const tokenRe = new RegExp(`<\\/?${tag}\\b[^>]*>`, 'gi'); tokenRe.lastIndex = start; let end = -1; let tok: RegExpExecArray | null; // eslint-disable-next-line no-cond-assign while ((tok = tokenRe.exec(out)) !== null) { i = tok.index; if (tok[0].startsWith(' !/^\s*>/.test(line)) .join('\n'); } else { let html = removeBalancedBlocks(body, new RegExp(`]*\\s${QUOTED_BLOCK_ATTR}\\b`), 'div'); html = removeBalancedBlocks(html, /` with `` * (and dropping the data-cid attribute). Returns the rewritten HTML and the * set of CIDs actually referenced so the sender can include exactly the * needed inline parts. */ export function rewriteInlineImages(html: string): { html: string; usedCids: string[] } { if (!html || !/data-cid=/i.test(html)) return { html, usedCids: [] }; const used = new Set(); const out = html.replace( /]*?)\sdata-cid=("([^"]*)"|'([^']*)')([^>]*)>/gi, (_full, before: string, _quoted: string, dq: string | undefined, sq: string | undefined, after: string) => { const cid = dq ?? sq ?? ''; if (!cid) return _full; used.add(cid); // Drop any existing src= and replace with cid: form. const merged = (before + after) .replace(/\ssrc=("[^"]*"|'[^']*')/gi, '') .trim(); const space = merged ? ' ' + merged : ''; return ``; }, ); return { html: out, usedCids: Array.from(used) }; } // Transparent 1x1 GIF used as a stand-in src while the real inline image is // being fetched from JMAP (webmail #163). export const INLINE_IMAGE_PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; /** * Rewrites `` references into * `` so the editor can render the * quoted original (a `cid:` URL would show a broken image) while still * carrying the cid through edits. Returns the cids found. */ export function rewriteCidImagesForEditor(html: string): { html: string; cids: string[] } { if (!html || html.indexOf('cid:') === -1) return { html, cids: [] }; const cids = new Set(); const out = html.replace( /]*?)\ssrc=("cid:([^"]*)"|'cid:([^']*)')([^>]*)>/gi, (_full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => { const cid = (dq ?? sq ?? '').trim(); if (!cid) return _full; cids.add(cid); const rest = (before + after).replace(/\sdata-cid=("[^"]*"|'[^']*')/gi, '').trim(); return ``; }, ); return { html: out, cids: Array.from(cids) }; } /** * Replace the placeholder src on `` elements with the * resolved data URL once the inline blob has been fetched. */ export function replaceInlineImagePlaceholders( html: string, cidToDataUrl: Map, ): string { if (!html || cidToDataUrl.size === 0 || html.indexOf('data-cid') === -1) return html; return html.replace( /]*?)\sdata-cid=("([^"]*)"|'([^']*)')([^>]*)>/gi, (full, before: string, _q: string, dq: string | undefined, sq: string | undefined, after: string) => { const cid = dq ?? sq ?? ''; const dataUrl = cidToDataUrl.get(cid); if (!dataUrl) return full; const rest = (before + after).replace(/\ssrc=("[^"]*"|'[^']*')/gi, '').trim(); return ``; }, ); } /** * Sniffs the real image MIME type from a blob's leading magic bytes, returning * null when the bytes aren't a recognizable image (webmail #543). */ export function sniffImageMime(bytes: Uint8Array): string | null { if (!bytes || bytes.length < 4) return null; if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png'; if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'; if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return 'image/gif'; 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'; if (bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp'; return null; }