Files
native/src/lib/email-html.ts
T

1049 lines
42 KiB
TypeScript

// 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.
// `<style>` is kept on purpose: the WebView is an isolated document, so the
// email's own stylesheet can't leak into the app, and stripping it destroyed
// class-based colours (native #46/#49). When external content is blocked the
// sheet is scrubbed of url()/@import/@font-face references instead.
import { DARK_PENDING_CLASS, emailColorScheme, emailDarkModeCss, resolveColorSchemeMedia } from './email-dark-mode';
import { mailFontStack, readingFontFaceCss, type AppFont } from '../theme/reading-fonts';
const FORBIDDEN_TAGS = [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'textarea', 'select', 'option',
'meta', 'link', 'base', 'svg', 'math',
'frame', 'frameset', 'applet', 'portal',
// Mutation-XSS / parser-quirk vectors. `noscript` flips parsing modes when
// JS is enabled, `xmp`/`plaintext`/`listing` switch the parser into raw-text
// mode and can stash code that re-emerges after later transforms. Drop them
// wholesale rather than try to reason about their interaction with the
// strip pipeline below.
'noscript', 'noembed', 'noframes', 'xmp', 'plaintext', 'listing',
'template', 'slot', 'shadow',
];
// HTML comments can hide otherwise-stripped tags and survive certain DOM
// transforms (especially conditional comments, which old IE parsed as code).
// Strip comments too so nothing rides along inside them.
function stripHtmlComments(html: string): string {
return html.replace(/<!--[\s\S]*?-->/g, '');
}
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
export function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]!);
}
/**
* 1x1 transparent GIF used to replace a blocked external <img> so the layout
* doesn't reflow to a broken-image icon. The real URL is stashed in
* `data-blocked-src`.
*/
export const TRANSPARENT_BLOCKED_PIXEL =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
/** Raster image types that stay allowed as a data: URI (mirrors the webmail allowlist). */
const RASTER_IMAGE_DATA_URI =
/^data:image\/(?:png|jpe?g|gif|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)[;,]/i;
// ─── Tag tokenizer ───────────────────────────────────────────────────────
//
// The attribute passes read markup the way the browser's HTML tokenizer does
// (WHATWG HTML §13.2.5), not with a tag regex. A regex that disagrees with
// the browser about where a tag or an attribute starts is a bypass: the old
// one wanted whitespace before `on*`, so `<img src="x"/onerror=…>` kept its
// handler, and a stray quote could hide a whole tag from it. Every tag is
// rebuilt in one canonical form (`name="value"`, no stray quotes), comments
// and bogus comments are dropped, and a `<` that starts no tag is written as
// `&lt;`, so later passes and the WebView see the same tags this one did.
interface ParsedAttr {
name: string;
/** Raw value (character references intact), or null for a boolean attribute. */
value: string | null;
}
interface ParsedTag {
/** Tag name as written. */
name: string;
attrs: ParsedAttr[];
selfClosing: boolean;
/** Index just past the closing `>`. */
end: number;
}
function isHtmlSpace(ch: string | undefined): boolean {
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\f' || ch === '\r';
}
function isAsciiAlpha(ch: string | undefined): boolean {
if (!ch) return false;
const code = ch.charCodeAt(0) | 0x20;
return code >= 97 && code <= 122;
}
/** The browser lowercases tag and attribute names in ASCII only. */
function asciiLower(s: string): string {
return s.replace(/[A-Z]+/g, (m) => m.toLowerCase());
}
/**
* Read a start or end tag whose name begins at `i`. Returns null when the
* input ends inside the tag: the browser drops such a tag and everything
* after it.
*/
function readTag(html: string, i: number): ParsedTag | null {
const n = html.length;
let j = i;
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '/' && html[j] !== '>') j++;
const name = html.slice(i, j);
const attrs: ParsedAttr[] = [];
while (j < n) {
const ch = html[j];
if (isHtmlSpace(ch)) { j++; continue; }
if (ch === '>') return { name, attrs, selfClosing: false, end: j + 1 };
if (ch === '/') {
// A slash only self-closes right before `>`; anywhere else it just
// separates attributes, which is what `<img/onerror=…>` relies on.
j++;
if (html[j] === '>') return { name, attrs, selfClosing: true, end: j + 1 };
continue;
}
// Attribute name. Its first character may be `=`; after that it runs to
// whitespace, `/`, `>` or `=`. Quotes and `<` are part of the name.
const start = j;
j++;
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '/' && html[j] !== '>' && html[j] !== '=') j++;
const attrName = html.slice(start, j);
while (j < n && isHtmlSpace(html[j])) j++;
if (html[j] !== '=') {
attrs.push({ name: attrName, value: null });
continue;
}
j++;
while (j < n && isHtmlSpace(html[j])) j++;
if (j >= n) return null;
const q = html[j];
if (q === '"' || q === "'") {
const close = html.indexOf(q, j + 1);
if (close < 0) return null;
attrs.push({ name: attrName, value: html.slice(j + 1, close) });
j = close + 1;
} else if (q === '>') {
attrs.push({ name: attrName, value: '' });
} else {
const s = j;
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '>') j++;
attrs.push({ name: attrName, value: html.slice(s, j) });
}
}
return null;
}
// Elements whose content the tokenizer reads as text up to their end tag.
// Only `style` and `title` survive the sanitiser; the rest are listed so a
// forbidden one is dropped together with its content.
const RAW_TEXT_TAGS = new Set([
'style', 'title', 'script', 'textarea', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript',
]);
/**
* Kept raw text with every `</` written so that no parser reads an end tag
* in it. Here only `</style` ends a sheet, but the sanitised markup is also
* embedded elsewhere: the composer seeds its editor with it inside an
* inline script, where a `</script` in a quoted sheet ended the script. In
* CSS `\/` is the same `/`; a title's text is RCDATA, where `&lt;` is `<`.
*/
function closeProofRawText(tag: string, text: string): string {
if (tag === 'style') return text.replace(/<\//g, '<\\/');
if (tag === 'title') return text.replace(/<\//g, '&lt;/');
return text;
}
// Attribute names written in a form that every later pass reads the same way.
const SAFE_ATTR_NAME = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
function serializeAttrs(attrs: ParsedAttr[]): string {
return attrs
.map((a) => (a.value === null ? a.name : `${a.name}="${a.value.replace(/"/g, '&quot;')}"`))
.join(' ');
}
function serializeTag(name: string, attrs: ParsedAttr[], selfClosing: boolean): string {
const body = serializeAttrs(attrs);
return `<${name}${body ? ' ' + body : ''}${selfClosing ? ' /' : ''}>`;
}
/**
* Tokenize `html` and rebuild every start tag through `fn`, which gets the
* ASCII-lowercased tag name and the attributes (first of each name only, as
* the browser keeps them) and returns the list to write, `null` to keep the
* attributes unchanged, or `false` to drop the tag. End tags are written
* without attributes; comments, doctypes and bogus comments are dropped.
* `rawText`, when given, rewrites the text of each kept raw-text element
* (a `<style>` sheet): the tokenizer knows where an element really starts,
* which a regex over the markup doesn't (it found `<style>` inside an
* attribute value).
*/
function rewriteTags(
html: string,
fn: (tag: string, attrs: ParsedAttr[]) => ParsedAttr[] | null | false,
rawText?: (tag: string, text: string) => string,
): string {
const n = html.length;
let out = '';
let i = 0;
while (i < n) {
const lt = html.indexOf('<', i);
if (lt < 0) {
out += html.slice(i);
break;
}
out += html.slice(i, lt);
const next = html[lt + 1];
if (isAsciiAlpha(next)) {
const tag = readTag(html, lt + 1);
if (!tag) break;
const lower = asciiLower(tag.name);
const seen = new Set<string>();
const attrs = tag.attrs.filter((a) => {
const key = asciiLower(a.name);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const result = fn(lower, attrs);
i = tag.end;
if (result !== false) out += serializeTag(tag.name, result ?? attrs, tag.selfClosing);
if (RAW_TEXT_TAGS.has(lower)) {
// Copy the element's text verbatim up to its end tag, which the loop
// then reads as a tag. A dropped element loses its text too.
const close = new RegExp(`</${lower}[\\t\\n\\f\\r />]`, 'gi');
close.lastIndex = i;
const m = close.exec(html);
const textEnd = m ? m.index : n;
if (result !== false) {
const text = html.slice(i, textEnd);
out += closeProofRawText(lower, rawText ? rawText(lower, text) : text);
}
i = textEnd;
}
continue;
}
if (next === '/') {
const after = html[lt + 2];
if (isAsciiAlpha(after)) {
const tag = readTag(html, lt + 2);
if (!tag) break;
if (fn(asciiLower(tag.name), []) !== false) out += `</${tag.name}>`;
i = tag.end;
continue;
}
if (after === '>') {
i = lt + 3;
continue;
}
if (after === undefined) {
out += '&lt;/';
break;
}
// Bogus comment up to the next `>`.
const gt = html.indexOf('>', lt + 2);
i = gt < 0 ? n : gt + 1;
continue;
}
if (next === '!') {
if (html.startsWith('<!--', lt)) {
// A comment ends at `-->` or `--!>`; `<!-->` and `<!--->` end at once.
const k = lt + 4;
if (html[k] === '>') { i = k + 1; continue; }
if (html.startsWith('->', k)) { i = k + 2; continue; }
const a = html.indexOf('-->', k);
const b = html.indexOf('--!>', k);
if (a < 0 && b < 0) { i = n; continue; }
i = a >= 0 && (b < 0 || a < b) ? a + 3 : b + 4;
continue;
}
// Doctype, CDATA outside foreign content and other bogus comments all
// end at the first `>`.
const gt = html.indexOf('>', lt + 2);
i = gt < 0 ? n : gt + 1;
continue;
}
if (next === '?') {
const gt = html.indexOf('>', lt + 2);
i = gt < 0 ? n : gt + 1;
continue;
}
// A `<` that starts no tag is text.
out += '&lt;';
i = lt + 1;
}
return out;
}
/** Rebuild every start tag through `fn`, which may edit its attribute list. */
function mapTags(
html: string,
fn: (tag: string, attrs: ParsedAttr[]) => ParsedAttr[] | null,
rawText?: (tag: string, text: string) => string,
): string {
return rewriteTags(html, fn, rawText);
}
const FORBIDDEN_TAG_SET = new Set(FORBIDDEN_TAGS);
// `on*` handlers, and the rarer `formmethod`, `srcdoc`, `ping` families that
// have a side-channel.
const DANGEROUS_ATTR = /^(?:on|srcdoc$|ping$|formmethod$|formenctype$|formtarget$|form$)/;
const URI_ATTRS = new Set(['href', 'src', 'action', 'xlink:href', 'formaction', 'poster', 'background']);
/** Drop forbidden tags and dangerous attributes, neutralise unsafe URIs. */
function sanitizeTags(html: string): string {
return rewriteTags(html, (tag, attrs) => {
if (FORBIDDEN_TAG_SET.has(tag)) return false;
const kept: ParsedAttr[] = [];
for (const attr of attrs) {
if (!SAFE_ATTR_NAME.test(attr.name)) continue;
const name = asciiLower(attr.name);
if (DANGEROUS_ATTR.test(name)) continue;
if (attr.value !== null && URI_ATTRS.has(name)) {
kept.push({ name: attr.name, value: safeUri(attr.value) });
} else if (attr.value !== null && name === 'srcset') {
// srcset is a list: check every candidate's scheme individually so
// an allowed first candidate can't wave a `data:image/svg+xml` through.
kept.push({ name: attr.name, value: safeSrcset(attr.value) });
} else {
kept.push(attr);
}
}
return kept;
});
}
export function stripDangerousTags(html: string): string {
// Strip comments first so they can't hide forbidden tags from the loops below.
let out = stripHtmlComments(html);
// Remove entire forbidden tag blocks (paired). Non-greedy across lines.
// Loop-until-stable so nested forbidden tags collapse fully.
let prev: string;
do {
prev = out;
for (const tag of FORBIDDEN_TAGS) {
const paired = new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}\\s*>`, 'gi');
out = out.replace(paired, '');
const selfClosing = new RegExp(`<${tag}\\b[^>]*\\/?>`, 'gi');
out = out.replace(selfClosing, '');
const closing = new RegExp(`<\\/${tag}\\s*>`, 'gi');
out = out.replace(closing, '');
}
} while (out !== prev);
// Then read what is left the way the browser will and rebuild every tag:
// dangerous attributes and unsafe URIs go whatever precedes them, and
// whatever the passes above left half-cut is settled one way.
return sanitizeTags(out);
}
function decodeUriEntities(raw: string): string {
return raw
.replace(/&#x([0-9a-f]+);?/gi, (_m, hex: string) => String.fromCharCode(parseInt(hex, 16)))
.replace(/&#(\d+);?/g, (_m, dec: string) => String.fromCharCode(parseInt(dec, 10)))
.replace(/&(?:amp|colon|tab|newline);/gi, (m) =>
m.toLowerCase() === '&amp;' ? '&' : m.toLowerCase() === '&colon;' ? ':' : '',
)
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0020]+/g, '');
}
function safeUri(raw: string): string {
// Decode HTML entities and strip control chars before scheme matching:
// `&#x6A;avascript:` and `java\nscript:` are the two classic bypass shapes
// that survive a naive prefix check.
const trimmed = decodeUriEntities(raw).toLowerCase();
// Block every non-allowlisted scheme rather than denylist-known-bad. Any
// scheme followed by a colon that isn't on the allow list goes to #blocked.
const schemeMatch = trimmed.match(/^([a-z][a-z0-9+.-]*):/);
if (schemeMatch) {
const scheme = schemeMatch[1];
const allowed = new Set(['http', 'https', 'mailto', 'tel', 'sms', 'cid']);
if (allowed.has(scheme)) return raw;
if (scheme === 'data') {
// Only raster data:image/* is safe to inline. SVG is excluded because
// the regex sanitizer cannot inspect bytes inside a data: URI, so an SVG
// payload can carry <script>/<foreignObject> it never sees.
return RASTER_IMAGE_DATA_URI.test(trimmed) ? raw : '#blocked';
}
return '#blocked';
}
// No scheme: relative URL, fragment, or plain text. Safe to keep as-is.
return raw;
}
/**
* srcset candidates are comma-separated `url [descriptor]` pairs, but a data:
* URI itself contains commas, so the value can't be split reliably. Check
* every `data:` occurrence where it stands and drop the whole attribute when
* one is not an allowed raster image; otherwise keep only http(s)/cid/data
* candidates.
*/
function safeSrcset(value: string): string {
const dataUris = value.match(/data:[^,\s]*,?/gi);
if (dataUris && dataUris.some((uri) => !RASTER_IMAGE_DATA_URI.test(uri))) return '';
if (/data:/i.test(value)) return value;
const candidates = value.split(',').map((c) => c.trim()).filter(Boolean);
const kept = candidates.filter((cand) => {
const url = decodeUriEntities(cand.split(/\s+/)[0] ?? '').toLowerCase();
const scheme = url.match(/^([a-z][a-z0-9+.-]*):/)?.[1];
return !scheme || scheme === 'http' || scheme === 'https' || scheme === 'cid';
});
return kept.join(', ');
}
export function plainTextToSafeHtml(text: string): string {
const escaped = escapeHtml(text);
return escaped.replace(
/(https?:\/\/[^\s<>"']+)/g,
(url) => `<a href="${url}" rel="noopener noreferrer">${url}</a>`,
);
}
// ─── External-resource detection / blocking ──────────────────────────────
/**
* True if a resource URL would trigger an external (network) fetch once the
* engine normalizes it. The URL parser removes ASCII tab/newline characters
* anywhere in the string and trims leading/trailing C0-control + space before
* resolving, so `"\n\nhttps://t"` and `"h\ttps://t"` are both external even
* though they don't literally start with "https://". Protocol-relative
* `//host` is external too. data:, blob:, and cid: never count as external.
*/
export function isExternalResourceUrl(value: string | null | undefined): boolean {
if (!value) return false;
// eslint-disable-next-line no-control-regex
const normalized = value.replace(/[\u0000-\u0020]+/g, '');
return /^(?:https?:\/\/|\/\/)/i.test(normalized);
}
/**
* Decode CSS escape sequences so escaped tracking URLs can be recognised.
* `\68ttp://x` and `\000068ttp://x` both decode to `http://x`. Handles the two
* CSS escape forms: 1-6 hex digits (optionally followed by one whitespace)
* and a backslash before any other character.
*/
export function decodeCssEscapes(value: string): string {
return value.replace(/\\([0-9a-fA-F]{1,6})\s?|\\(.)/g, (_full, hex, char) => {
if (hex) return cssEscapeChar(parseInt(hex, 16));
return char ?? '';
});
}
// The character a hex escape stands for. Past U+10FFFF and surrogates are
// U+FFFD to CSS; String.fromCodePoint would throw on the former.
function cssEscapeChar(code: number): string {
if (!code) return '';
if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return '\ufffd';
return String.fromCodePoint(code);
}
/**
* `css` with its escapes decoded, plus for every decoded UTF-16 unit the
* index in `css` its source starts at (and `css.length` at the end), so a
* match in the decoded text can be cut out of the original.
*/
function decodeCssEscapesMapped(css: string): { text: string; from: number[] } {
const parts: string[] = [];
const from: number[] = [];
const escape = /\\([0-9a-fA-F]{1,6})\s?|\\(.)/y;
let i = 0;
while (i < css.length) {
const start = i;
let piece = css[i];
if (piece === '\\') {
escape.lastIndex = i;
const m = escape.exec(css);
if (m) {
piece = m[1] ? cssEscapeChar(parseInt(m[1], 16)) : m[2];
i += m[0].length;
} else {
i++;
}
} else {
i++;
}
parts.push(piece);
for (let k = 0; k < piece.length; k++) from.push(start);
}
from.push(css.length);
return { text: parts.join(''), from };
}
const CSS_URL_PATTERN = /url\(\s*(['"]?)([^)]*?)\1\s*\)/gi;
const CSS_EXTERNAL_IMPORT = /@import\s+(['"])\s*(?:https?:)?\/\/[^'"]*\1[^;]*;?/gi;
/**
* Neutralise external references in CSS: background `url()`, `@font-face`
* sources and `@import`. Matching runs on the text with its escapes decoded,
* because the "css escape" tracker escapes the `url` keyword itself
* (`\75\72\6C(` -> `url(`), but only the matches are cut from the original:
* the rest keeps its escapes. Writing the decoded text back turned
* `\5c 75rl(` into `\75rl(`, which the engine reads as `url(` again, and an
* escaped quote into one that ends the string. Returns the original string
* when nothing external is present, so callers can detect a change by
* identity. (#457)
*/
export function stripExternalStyleSheetCss(css: string): string {
if (!css || !/url|@import|\\/i.test(css)) return css;
const { text, from } = decodeCssEscapesMapped(css);
const cuts: Array<{ start: number; end: number; with: string }> = [];
for (const m of text.matchAll(CSS_URL_PATTERN)) {
if (isExternalResourceUrl(m[2])) cuts.push({ start: m.index, end: m.index + m[0].length, with: 'url()' });
}
for (const m of text.matchAll(CSS_EXTERNAL_IMPORT)) {
cuts.push({ start: m.index, end: m.index + m[0].length, with: '' });
}
if (cuts.length === 0) return css;
cuts.sort((a, b) => a.start - b.start);
let out = '';
let at = 0;
for (const cut of cuts) {
const start = from[cut.start];
if (start < at) continue;
out += css.slice(at, start) + cut.with;
at = from[cut.end];
}
return out + css.slice(at);
}
/** True if any `url(...)` in a CSS string resolves to an external resource. */
export function styleHasExternalUrl(style: string): boolean {
return stripExternalStyleSheetCss(style) !== style;
}
/** Replace every external `url(...)` in a CSS string with an empty `url()`. */
export function stripExternalCssUrls(style: string): string {
return stripExternalStyleSheetCss(style);
}
/** An attribute's URL as the browser reads it: character references decoded. */
function isExternalAttrUrl(value: string | null | undefined): boolean {
return !!value && isExternalResourceUrl(decodeUriEntities(value));
}
function srcsetHasExternalUrl(srcset: string): boolean {
return srcset
.split(',')
.some((candidate) => isExternalAttrUrl(candidate.trim().split(/\s+/)[0]));
}
function appendStyle(attrs: ParsedAttr[], extra: string): void {
const style = attrs.find((a) => a.name.toLowerCase() === 'style');
if (style) {
style.value = `${style.value ?? ''};${extra}`;
} else {
attrs.push({ name: 'style', value: extra });
}
}
export interface BlockExternalResult {
html: string;
/** True when at least one external resource was actually neutralised. */
blocked: boolean;
}
/**
* Neutralise every external-resource vector, stashing the original value in
* a `data-blocked-*` attribute. Covers the vectors beyond a bare `<img src>`:
* whitespace/newline in src, `<picture><source srcset>`, `<video poster>` /
* media src, the legacy `background` attribute, inline `style` url()
* (including CSS-escaped URLs) and `<style>` block CSS. The strict CSP the
* caller derives from the policy is the guaranteed network-level backstop;
* this pass drives the banner and the placeholder swap.
*/
export function blockExternalResources(html: string): BlockExternalResult {
let blocked = false;
const out = mapTags(html, (tag, attrs) => {
let changed = false;
const get = (name: string) => attrs.find((a) => a.name.toLowerCase() === name);
const remove = (name: string) => {
const i = attrs.findIndex((a) => a.name.toLowerCase() === name);
if (i >= 0) attrs.splice(i, 1);
};
// The parser reads an `<image>` start tag as `<img>`.
const isImg = tag === 'img' || tag === 'image';
if (isImg) {
const src = get('src');
if (src?.value && isExternalAttrUrl(src.value)) {
attrs.push({ name: 'data-blocked-src', value: src.value.trim() });
src.value = TRANSPARENT_BLOCKED_PIXEL;
const alt = get('alt');
if (alt) alt.value = ''; else attrs.push({ name: 'alt', value: '' });
appendStyle(attrs, 'display:none');
changed = true;
}
}
if (isImg || tag === 'source') {
const srcset = get('srcset');
if (srcset?.value && srcsetHasExternalUrl(srcset.value)) {
attrs.push({ name: 'data-blocked-srcset', value: srcset.value });
remove('srcset');
changed = true;
}
}
if (tag === 'source' || tag === 'video' || tag === 'audio') {
const src = get('src');
if (src?.value && isExternalAttrUrl(src.value)) {
attrs.push({ name: 'data-blocked-src', value: src.value.trim() });
remove('src');
changed = true;
}
}
if (tag === 'video' || tag === 'audio') {
const poster = get('poster');
if (poster?.value && isExternalAttrUrl(poster.value)) {
attrs.push({ name: 'data-blocked-poster', value: poster.value.trim() });
remove('poster');
changed = true;
}
}
const bg = get('background');
if (bg?.value && isExternalAttrUrl(bg.value)) {
attrs.push({ name: 'data-blocked-background', value: bg.value.trim() });
remove('background');
changed = true;
}
const style = get('style');
if (style?.value && styleHasExternalUrl(style.value)) {
attrs.push({ name: 'data-blocked-style', value: style.value });
style.value = stripExternalCssUrls(style.value);
changed = true;
}
if (changed) blocked = true;
return changed ? attrs : null;
}, (tag, css) => {
// <style> sheets, read where the tokenizer finds them: a regex over the
// markup also matched `<style>` inside an attribute value and rewrote
// the attributes after it.
if (tag !== 'style') return css;
const stripped = stripExternalStyleSheetCss(css);
if (stripped !== css) blocked = true;
return stripped;
});
return { html: out, blocked };
}
/** True when the HTML references at least one external (network) resource. */
export function hasRemoteContent(html: string): boolean {
return blockExternalResources(html).blocked;
}
// ─── Styles ──────────────────────────────────────────────────────────────
// Base styles for HTML emails - mirrors the webmail iframe body.
// Emails are authored for light mode; they are rendered true-to-life, and in
// dark mode their colours are rewritten after layout by the injected pass in
// ./email-dark-mode.
function baseStyles(bodyPadding: string): string {
return `
html { background: #ffffff; height: auto !important; }
/* The injected reporter lays wide content out at its natural width and scales
it down to fit; overflow-x: hidden kills any residual sideways scrolling. */
html, body { overflow-x: hidden; }
/* Some emails put height:100% on a full-bleed wrapper, which with our
auto-height measurement clips the content to a sliver. Neutralise it. */
[style*="height:100%"], [style*="height: 100%"] { height: auto !important; }
body {
margin: 0;
padding: ${bodyPadding};
height: auto !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #1a1a1a;
background: #ffffff;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* A wide reading pane (tablet): keep lines readable instead of running
150+ characters across. Phones never reach the breakpoint. */
@media (min-width: 840px) {
body { max-width: 720px; margin-left: auto; margin-right: auto; }
}
/* Only force the cap on images and tables that set no max-width of their own:
an !important 100% also overrides a sender's inline max-width, so an image
sized by max-width + max-height + width:100% grows to the pane width while
its max-height still clamps the height (squashed), and a table with
max-width:300px spans the pane. Content wider than the pane is still
scaled to fit by the injected reporter. height stays !important so a fixed
inline height cannot squash an image. (#790) */
img:not([style*="max-width"]) { max-width: 100% !important; }
img[style*="max-width"] { max-width: 100%; }
img { height: auto !important; }
a { color: #1a73e8; }
table:not([style*="max-width"]) { max-width: 100% !important; }
table[style*="max-width"] { max-width: 100%; }
table { table-layout: auto; overflow-wrap: break-word; }
td, th { overflow-wrap: break-word; }
pre { white-space: pre-wrap; word-wrap: break-word; }
`;
}
// Word emails rely on empty <p class=MsoNormal>&nbsp;</p> spacers for vertical
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
// tighten to match how Outlook/Gmail render the same source.
const WORD_HTML_CSS = `
body { line-height: 1.15; }
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
`;
// Matches the webmail's `emailHasNativeDarkMode` detector: true if the HTML
// contains a `@media (prefers-color-scheme: dark)` rule.
export function hasNativeDarkMode(html: string): boolean {
return /prefers-color-scheme\s*:\s*dark/i.test(html);
}
function isWordHtml(html: string): boolean {
return /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(html);
}
// The parser merges the attributes of an email's own <html>/<body> tags into
// our document's where ours lack them, so a direction the sender declared
// there applies as long as we don't set one first.
function declaresOwnDirection(html: string): boolean {
return /<(?:html|body)\b[^>]*\sdir\s*=/i.test(html);
}
// "auto" spacing: only drop our gutter when the mail paints a full-bleed
// background canvas (a width:100% element carrying a background colour) - the
// one case where the gutter shows as a frame around the email's own
// background.
function hasFullBleedCanvas(html: string): boolean {
return (
/<(?:table|div|body)\b[^>]*(?:\bwidth\s*=\s*["']?\s*100%|width\s*:\s*100%)[^>]*(?:\bbgcolor\s*=|background(?:-color)?\s*:)/i.test(html) ||
/<(?:table|div|body)\b[^>]*(?:\bbgcolor\s*=|background(?:-color)?\s*:)[^>]*(?:\bwidth\s*=\s*["']?\s*100%|width\s*:\s*100%)/i.test(html)
);
}
// ─── cid: inline images ──────────────────────────────────────────────────
// Returns the set of cid references (without the `cid:` prefix or angle
// brackets) used by <img src="cid:...">, for callers that prefetch inline blobs.
export function extractCidRefs(html: string): string[] {
const out = new Set<string>();
const re = /\bcid:([^"'\s)>]+)/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(html)) !== null) {
out.add(m[1].replace(/^<|>$/g, ''));
}
return Array.from(out);
}
/**
* A resolved inline image: an image type and nothing but base64 after it.
* The type comes from the part's Content-Type when the bytes aren't a known
* format, and the server hands that header over verbatim, quotes and `>`
* included.
*/
const INLINE_IMAGE_DATA_URI = /^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]*$/i;
export function isInlineImageDataUri(uri: string | null | undefined): boolean {
return !!uri && INLINE_IMAGE_DATA_URI.test(uri);
}
// Swap `cid:` references for their resolved data: URI inside src (and the
// legacy background) attributes only, through the tokenizer, on the
// sanitised markup. A string replace on the whole document also rewrote
// text and other attributes, and wrote a map value into the markup as is.
function replaceCidRefs(html: string, cidMap: Record<string, string>): string {
return mapTags(html, (_tag, attrs) => {
let changed = false;
for (const attr of attrs) {
if (attr.value === null) continue;
const name = asciiLower(attr.name);
if (name !== 'src' && name !== 'background') continue;
const raw = attr.value.trim();
const url = /^cid:/i.test(raw) ? raw : decodeUriEntities(raw);
if (!/^cid:/i.test(url)) continue;
const uri = cidMap[url.slice(4).replace(/^<|>$/g, '')];
attr.value = isInlineImageDataUri(uri) ? uri : TRANSPARENT_BLOCKED_PIXEL;
changed = true;
}
return changed ? attrs : null;
});
}
// ─── Document assembly ───────────────────────────────────────────────────
export type MessageSpacing = 'auto' | 'always' | 'edge';
export interface WrapOptions {
/** Neutralise external resources and switch to the strict CSP. */
blockRemoteImages?: boolean;
cidMap?: Record<string, string>;
/** When true, the host renders in dark mode: the head gets the dark
* fallback colours and the body stays hidden until the recolour pass ran. */
isDark?: boolean;
messageSpacing?: MessageSpacing;
}
export interface PreparedEmailHtml {
html: string;
/** Shown dark: the recolour pass (./email-dark-mode) has to run. */
recolorDark: boolean;
/** The email ships its own `prefers-color-scheme: dark` rules. */
hasNativeDark: boolean;
/** At least one external resource was neutralised. */
blockedExternal: boolean;
}
function buildCsp(strict: boolean): string {
// Strict CSP. The document carries no script of its own: the measurement,
// swipe, zoom and quote bridges arrive as react-native-webview's injected
// JavaScript (evaluateJavascript on Android, WKUserScript/evaluateJavaScript
// on iOS), which page CSP doesn't govern, and they attach listeners with
// addEventListener. So script-src is 'none': if markup ever slips past
// `stripDangerousTags`, its inline handlers and scripts still don't run.
//
// In blocking mode img/media/font are restricted to data: only - the
// network-level backstop for every tracking vector, including ones the
// attribute pass can't see.
const resources = strict
? ['img-src data: cid:', 'font-src data:', "media-src 'none'"]
: ['img-src data: cid: https: http:', 'font-src data: https: http:', 'media-src data: https: http:'];
return [
"default-src 'none'",
"script-src 'none'",
"style-src 'unsafe-inline'",
...resources,
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
].join('; ');
}
/**
* Turn a raw HTML body into a complete, sandboxed document. `recolorDark` is
* returned so the caller injects the recolour pass exactly when the head
* hides the body for it (a mismatch between the CSS decision and the script
* decision was native #49).
*/
export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}): PreparedEmailHtml {
const { blockRemoteImages = false, cidMap, isDark = true, messageSpacing = 'auto' } = options;
const cleaned = stripDangerousTags(innerHtml);
const withCids = cidMap ? replaceCidRefs(cleaned, cidMap) : cleaned;
let processed = withCids;
let blockedExternal = false;
if (blockRemoteImages) {
const res = blockExternalResources(withCids);
processed = res.html;
blockedExternal = res.blocked;
}
const hasNativeDark = hasNativeDarkMode(processed);
// The recolour pass runs on every mail shown dark, also one with its own
// dark styles: those are pinned to the app's theme below, and the pass
// leaves colours that are already dark or readable alone (webmail
// email-viewer.tsx recolorDark).
const recolorDark = isDark;
const colorScheme = emailColorScheme(isDark, hasNativeDark);
const darkCss = recolorDark ? emailDarkModeCss() : '';
const rootClass = recolorDark ? ` class="${DARK_PENDING_CLASS}"` : '';
const word = isWordHtml(processed);
const hasStyleTag = /<style\b/i.test(processed);
const autoDropsGutter = hasStyleTag && !word && hasFullBleedCanvas(processed);
const dropGutter = messageSpacing === 'edge' || (messageSpacing === 'auto' && autoDropsGutter);
const bodyPadding = dropGutter ? '0' : '16px';
// dir="auto" lets the first strong character pick the direction, so
// right-to-left mail reads right-to-left.
const bodyDir = declaresOwnDirection(processed) ? '' : ' dir="auto"';
const html = `<!doctype html>
<html${rootClass} style="color-scheme: ${colorScheme};">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta http-equiv="Content-Security-Policy" content="${buildCsp(blockRemoteImages)}">
<meta name="referrer" content="no-referrer">
<style>${baseStyles(bodyPadding)}${word ? WORD_HTML_CSS : ''}${darkCss}</style>
</head>
<body${bodyDir}>${resolveColorSchemeMedia(processed, isDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body>
</html>`;
return { html, recolorDark, 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.
/** The app theme's colours for a plain-text body read in dark mode. */
export interface PlainTextColors {
background: string;
text: string;
link: string;
}
/** A theme colour for a style sheet: nothing that could end the declaration or the element. */
function cssColor(value: string, fallback: string): string {
const cleaned = value.replace(/[<>{};\\]/g, '').trim();
return cleaned || fallback;
}
export function wrapPlainTextEmail(
innerHtml: string,
options: {
isDark?: boolean;
font?: PlainTextFont;
fontPx?: number;
darkColors?: PlainTextColors;
/**
* The app's reading font (Settings > Accessibility > Font). "Same as
* above" (sans) draws the body in it, as the webmail does for a plain
* body in the app document; monospace stays monospace.
*/
readingFont?: AppFont;
} = {},
): string {
const { isDark = true, font = 'sans', fontPx = 14, darkColors, readingFont } = options;
const cleaned = stripDangerousTags(innerHtml);
const reading = font === 'sans' ? readingFont : undefined;
// In dark mode the body takes the app theme's ground, text and link colours
// when they are given (a built-in dark theme isn't #0a0a0a); light mode
// stays on white like HTML mail.
const bg = isDark ? cssColor(darkColors?.background ?? '', '#0a0a0a') : '#ffffff';
const fg = isDark ? cssColor(darkColors?.text ?? '', '#fafafa') : '#1a1a1a';
const link = isDark ? cssColor(darkColors?.link ?? '', '#60a5fa') : '#1a73e8';
const fontFamily = font === 'mono'
? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace'
: mailFontStack(reading);
const styles = `${readingFontFaceCss(reading)}
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: ${fontPx}px;
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 `<!doctype html>
<html style="color-scheme: ${isDark ? 'dark' : 'light'};">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta http-equiv="Content-Security-Policy" content="${buildCsp(true)}">
<style>${styles}</style>
</head>
<body dir="auto">${cleaned}</body>
</html>`;
}
// Mirrors the webmail's `hasMeaningfulHtmlBody` (lib/signature-utils.ts).
// Returns false when the HTML is an auto-generated minimal wrapper around plain
// text (no <br>, 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|<a\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';
// The declared type goes into a data: URI, and the server returns the
// part's Content-Type verbatim: only a plain `image/<subtype>` is used.
const f = (fallback || '').toLowerCase().split(';')[0].trim();
if (/^image\/[a-z0-9.+-]+$/.test(f)) 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;
}