// Safe HTML shaping for email bodies rendered inside a sandboxed WebView. // // Layered defenses (in order of importance): // 1. The WebView runs the content with `originWhitelist={['about:blank']}` // and a strict Content-Security-Policy meta (default-src 'none'), so // external resources are blocked by the engine itself unless the policy // allows them. This is the primary boundary. // 2. `onShouldStartLoadWithRequest` refuses any navigation except the // initial about:blank doc - links are opened in the OS browser / composer. // 3. This module pre-strips known-dangerous tags and on* attributes so the // rendered DOM is also clean. Regex stripping is imperfect; it is here // as belt-and-suspenders, not the primary defense. The attribute pass // reads tags the way the browser's tokenizer does, and the page CSP is // `script-src 'none'` (the bridge scripts are injected by the WebView, // which CSP doesn't govern), so an inline handler that slipped through // would still not run. // // Tag/attribute lists mirror `lib/email-sanitization.ts` from the webmail. // ` ${processed} `; return { html, applyInversion, hasNativeDark, blockedExternal }; } export function wrapEmailHtml(innerHtml: string, options: WrapOptions = {}): string { return prepareEmailHtml(innerHtml, options).html; } export type PlainTextFont = 'sans' | 'mono'; // Wrap a plain-text body (already run through `plainTextToSafeHtml`) for the // WebView. Mirrors the webmail's plain-text render path: app font by default // (monospace on request, #830), `white-space: pre-wrap`, native dark theme // (no filter inversion), and a dark-mode-appropriate link colour. export function wrapPlainTextEmail( innerHtml: string, options: { isDark?: boolean; font?: PlainTextFont } = {}, ): string { const { isDark = true, font = 'sans' } = options; const cleaned = stripDangerousTags(innerHtml); const bg = isDark ? '#0a0a0a' : '#ffffff'; const fg = isDark ? '#fafafa' : '#1a1a1a'; const link = isDark ? '#60a5fa' : '#1a73e8'; const fontFamily = font === 'mono' ? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace' : '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; const styles = ` html, body { background: ${bg}; } /* Pinch-zoomed content overflows sideways; it is panned via the injected transform, never via native horizontal scrolling. */ html, body { overflow-x: hidden; } body { margin: 0; padding: 16px; font-family: ${fontFamily}; font-size: 14px; line-height: 1.6; color: ${fg}; white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; } a { color: ${link}; text-decoration: underline; } details > summary::-webkit-details-marker { display: none; } /* A wide reading pane (tablet): about 80 characters a line. */ @media (min-width: 840px) { body { max-width: 80ch; } } `; return ` ${cleaned} `; } // Mirrors the webmail's `hasMeaningfulHtmlBody` (lib/signature-utils.ts). // Returns false when the HTML is an auto-generated minimal wrapper around plain // text (no
, no links, no rich tags) - the caller should fall back to the // textBody in that case, since the server-side HTML often collapses newlines. const MEANINGFUL_HTML_RE = /<(?:table|img|style|b|strong|i|em|u|font|h[1-6]|ul|ol|blockquote|br)\b|]*\bhref=|<(?:div|span|p)\b[^>]*\bstyle=/i; export function hasMeaningfulHtmlBody(html: string): boolean { if (!html.trim()) return false; if (MEANINGFUL_HTML_RE.test(html)) return true; // Fallback: more than one block element suggests structure. const blockMatches = html.match(/<(?:p|div|blockquote|li)\b/gi); return (blockMatches?.length ?? 0) > 1; } // Inspect the first bytes of an inline image so the data: URI carries a MIME // the engine will decode. Some clients label cid parts `application/octet- // stream` (#543), which Chromium/WebKit refuse to render as an image. export function sniffImageMime(bytes: Uint8Array, fallback?: string): string { if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png'; if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'; if (bytes.length >= 6 && 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.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp'; const f = (fallback || '').toLowerCase().split(';')[0].trim(); if (f.startsWith('image/')) return f; return 'application/octet-stream'; } /** * Whether a navigation request from the message WebView is the user tapping * a link in the top document. iOS reports how a navigation started; Android * leaves `navigationType` at 'other' for everything, so there only the frame * check applies. */ export function isUserLinkNavigation( request: { navigationType?: string; isTopFrame?: boolean }, platform: string, ): boolean { if (request.isTopFrame === false) return false; if (platform === 'ios') return request.navigationType === 'click'; return true; }