Files
ihasmail-inbuxa/web/src/lib/html.ts
T

379 lines
18 KiB
TypeScript

import DOMPurify from "dompurify";
import { withBase } from "@/lib/basePath";
export interface SanitizeOptions {
/** Map of Content-ID (without angle brackets) → URL for inline images. */
cidMap?: Record<string, string>;
/** Whether remote content (http/https images, css urls) may load. */
allowRemote?: boolean;
/** Route remote images through the privacy proxy. */
proxyRemote?: boolean;
}
export interface SanitizeResult {
html: string;
remoteCount: number;
bodyStyle: string;
}
const REMOTE_URL_RE = /^(https?:)?\/\//i;
const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
let hooked = false;
function ensureHooks() {
if (hooked) return;
hooked = true;
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
// Strip <style> in dark-mode-unfriendly cases? No - keep styles, we scope them in a shadow root.
if (data.tagName === "style" && node.textContent) {
// Remove @import and remote url() references; they're handled later in processRemote().
node.textContent = node.textContent.replace(/@import[^;]+;?/gi, "");
}
});
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer nofollow");
}
// Forms are forbidden but be safe about formaction-like attributes on anything.
for (const attr of ["formaction", "action", "ping", "xlink:href"]) {
if (node.hasAttribute(attr)) node.removeAttribute(attr);
}
});
}
/**
* Blunt the positioning tricks mail CSS can use to escape its card.
*
* A shadow root scopes selectors but not layout, so `position:fixed` in a
* message is still positioned against the viewport — enough to paint a
* convincing fake over the whole app. The control that actually stops that is
* layout containment on an ancestor of the shadow host (see `.message-body` in
* app.css), which mail CSS has no selector for. This is the second line:
* neutralize the declarations themselves, and defang `:host`, which is how mail
* CSS would otherwise reach the host element.
*/
function hardenCss(css: string): string {
return css
// `:host` / `:host-context` become a selector that matches nothing; where
// they took an argument the rule is left invalid, and so dropped.
.replace(/:host(-context)?/gi, ":not(*)")
.replace(/position\s*:\s*(fixed|sticky)/gi, "position:static");
}
export function proxiedImageUrl(url: string): string {
return withBase(`/api/image?url=${encodeURIComponent(url)}`);
}
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
ensureHooks();
let bodyStyle = "";
const bodyMatch = /<body([^>]*)>/i.exec(input);
if (bodyMatch) {
const attrs = bodyMatch[1]!;
const bg = /bgcolor\s*=\s*["']?([#\w()%,.\s-]+)["']?/i.exec(attrs)?.[1];
const style = /style\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? /style\s*=\s*'([^']*)'/i.exec(attrs)?.[1];
if (bg) bodyStyle += `background-color:${bg.trim()};`;
if (style) bodyStyle += style;
}
const clean = DOMPurify.sanitize(input, {
WHOLE_DOCUMENT: false,
RETURN_DOM: true,
FORBID_TAGS: ["script", "iframe", "frame", "frameset", "object", "embed", "applet", "form", "input", "button", "textarea", "select", "option", "meta", "link", "base", "svg", "math", "video", "audio", "source", "track", "canvas", "template", "slot", "dialog", "noscript"],
FORBID_ATTR: ["srcdoc", "formaction", "action", "ping", "autofocus", "autoplay", "contenteditable", "draggable", "tabindex"],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
USE_PROFILES: { html: true },
ADD_TAGS: ["style", "center", "font", "marquee"],
ADD_ATTR: ["bgcolor", "background", "valign", "align", "border", "cellpadding", "cellspacing", "width", "height", "color", "face", "size", "target"],
}) as unknown as HTMLElement;
let remoteCount = 0;
const cidMap = opts.cidMap ?? {};
const allow = Boolean(opts.allowRemote);
const proxy = Boolean(opts.proxyRemote);
const remote = (url: string): string => {
remoteCount++;
if (!allow) return "";
return proxy ? proxiedImageUrl(url) : url;
};
const rewriteUrl = (raw: string): { url: string; keep: boolean } => {
const url = raw.trim();
if (/^cid:/i.test(url)) {
const cid = url.slice(4).replace(/^<|>$/g, "");
const mapped = cidMap[cid] ?? cidMap[cid.toLowerCase()];
return mapped ? { url: mapped, keep: true } : { url: "", keep: false };
}
if (/^data:image\//i.test(url)) return { url, keep: true };
if (REMOTE_URL_RE.test(url)) {
const abs = url.startsWith("//") ? `https:${url}` : url;
const u = remote(abs);
return { url: u, keep: Boolean(u) };
}
// Relative or unknown scheme -> drop.
return { url: "", keep: false };
};
// Image-bearing attributes
const els = clean.querySelectorAll<HTMLElement>("[src],[background],[poster],[srcset]");
els.forEach((el) => {
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
for (const attr of ["src", "background", "poster"]) {
const v = el.getAttribute(attr);
if (v == null) continue;
const r = rewriteUrl(v);
if (r.keep) el.setAttribute(attr, r.url);
else {
el.removeAttribute(attr);
if (attr === "src" && el.tagName === "IMG") {
el.setAttribute("data-ihm-blocked", "1");
if (REMOTE_URL_RE.test(v)) el.setAttribute("data-ihm-remote", v.trim());
}
}
}
});
// CSS url() in style attributes and <style> blocks
const rewriteCss = (css: string): string =>
css.replace(CSS_URL_RE, (_m, q: string, u: string) => {
const r = rewriteUrl(u);
return r.keep ? `url(${q}${r.url}${q})` : "none";
});
clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
const s = el.getAttribute("style");
if (!s) return;
const out = hardenCss(/url\(/i.test(s) ? rewriteCss(s) : s);
if (out !== s) el.setAttribute("style", out);
});
clean.querySelectorAll("style").forEach((st) => {
const css = st.textContent ?? "";
if (!css) return;
st.textContent = hardenCss(rewriteCss(css.replace(/@import[^;]+;?/gi, "")));
});
if (bodyStyle && /url\(/i.test(bodyStyle)) bodyStyle = rewriteCss(bodyStyle);
return { html: clean.innerHTML, remoteCount, bodyStyle };
}
/** Minimal sanitizer for signatures / composer HTML (no remote blocking, keeps images). */
export function sanitizeEditorHtml(input: string): string {
ensureHooks();
return DOMPurify.sanitize(input, {
USE_PROFILES: { html: true },
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "style", "meta", "link", "base", "svg", "math"],
FORBID_ATTR: ["srcdoc", "formaction", "ping", "onerror", "onload"],
ADD_ATTR: ["target", "bgcolor", "align", "valign", "border", "cellpadding", "cellspacing", "width", "height", "color", "face", "size"],
}) as string;
}
/** Base CSS injected into the shadow root that hosts HTML email. */
export const EMAIL_BASE_CSS = `
:host { display:block; color-scheme: light; }
:host(.themed) { color-scheme: inherit; }
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; }
.ihm-email-root img { max-width:100%; height:auto; }
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
.ihm-email-root table { max-width:100%; }
.ihm-email-root pre { white-space:pre-wrap; }
.ihm-email-root blockquote { margin:0 0 0 .8ex; border-left:2px solid #cbd5e1; padding-left:1ex; color:#475569; }
.ihm-email-root a { color:#0f766e; }
.ihm-email-root * { max-width:100%; box-sizing:border-box; }
.ihm-email-root [style*="position:fixed"], .ihm-email-root [style*="position: fixed"] { position:static !important; }
/* "Follow the app theme" — only applied to mail that brings no colors of its
own. The custom properties are inherited from the host document, so a theme
switch repaints the message without re-rendering it. */
.ihm-email-root.themed { color: var(--fg, #1f2937); background: var(--bg-elev, #fff); }
.ihm-email-root.themed blockquote { border-left-color: var(--border-strong, #cbd5e1); color: var(--fg-muted, #475569); }
.ihm-email-root.themed a { color: var(--link, #0f766e); }
.ihm-email-root.themed hr { border-color: var(--border, #e3e7ec); }
.ihm-email-root.themed img[data-ihm-blocked] { background: var(--bg-sunken, #f1f5f9) repeating-linear-gradient(45deg, var(--bg-hover, #e2e8f0) 0 6px, transparent 6px 12px); border-color: var(--border-strong, #cbd5e1); }
/* "Even mail that styles itself" — the second, opt-in switch, applied on top of
.themed. Everything the sender colored is neutralized except the surfaces
marked by markKeptSurfaces() and what it marked as sitting on them, so a
white wrapper table
stops being a bright card while a blue button keeps its white label. The
sender's markup is untouched; this is all cascade, so the switch is
reversible and print still pins the tokens to ink on white. */
.ihm-email-root.forced { color: var(--fg, #1f2937) !important; background: var(--bg-elev, #fff) !important; }
.ihm-email-root.forced *:not([data-ihm-keep]):not([data-ihm-in-keep]) { color: inherit !important; background-color: transparent !important; }
.ihm-email-root.forced a:not([data-ihm-keep]):not([data-ihm-in-keep]) { color: var(--link, #0f766e) !important; }
`;
/**
* Does this message paint itself? Mail that sets a background or text color
* has a design of its own, and forcing a dark palette on half of it is worse
* than leaving it alone — so those keep the light card they were built for.
*
* The bar is deliberately low, and that is the point of the second switch
* (`themeStyledMessages`): in real mail this is true of very nearly everything.
* One `color:#FFFFFF` on one button label is enough, so a template that is
* plain in every way a reader would notice still counts as painting itself.
* See `markKeptSurfaces` for what the opt-in does about it.
*/
export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
const haystack = `${bodyStyle} ${html}`;
return (
/\bbgcolor\s*=/i.test(haystack) ||
/<font[^>]*\bcolor\s*=/i.test(haystack) ||
/(?:^|[;"'\s{])(?:background(?:-color)?|color)\s*:/i.test(haystack)
);
}
/* ---------- forcing the theme onto mail that styles itself ---------- */
/**
* Relative luminance per WCAG 2.x, or `null` when the color cannot be read.
*
* Only what actually turns up in mail is parsed: hex in three, six or eight
* digits, `rgb()`/`rgba()`, and the handful of names senders still write out.
* Anything else is `null`, which the caller treats as "not a deliberate
* surface" — the safe way round, because the failure it avoids is a white
* sheet surviving the switch the reader just turned on.
*/
const NAMED: Record<string, string> = {
white: "#ffffff", ivory: "#fffff0", snow: "#fffafa", whitesmoke: "#f5f5f5",
ghostwhite: "#f8f8ff", floralwhite: "#fffaf0", seashell: "#fff5ee", beige: "#f5f5dc",
linen: "#faf0e6", lightgray: "#d3d3d3", lightgrey: "#d3d3d3", gainsboro: "#dcdcdc",
silver: "#c0c0c0", gray: "#808080", grey: "#808080", black: "#000000",
navy: "#000080", darkblue: "#00008b", maroon: "#800000", teal: "#008080",
};
export function relativeLuminance(color: string): number | null {
const raw = color.trim().toLowerCase();
if (!raw || raw === "transparent" || raw === "inherit" || raw === "initial" || raw === "none") return null;
let r: number, g: number, b: number, a = 1;
const named = NAMED[raw];
const hex = (named ?? raw).match(/^#([0-9a-f]{3,8})$/);
if (hex) {
const h = hex[1]!;
if (h.length === 3) [r, g, b] = [h[0]! + h[0]!, h[1]! + h[1]!, h[2]! + h[2]!].map((x) => parseInt(x, 16)) as [number, number, number];
else if (h.length === 6 || h.length === 8) {
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
if (h.length === 8) a = parseInt(h.slice(6, 8), 16) / 255;
} else return null;
} else {
const m = raw.match(/^rgba?\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)(?:[\s,/]+([0-9.%]+))?\s*\)$/);
if (!m) return null;
r = Number(m[1]); g = Number(m[2]); b = Number(m[3]);
if (m[4] !== undefined) a = m[4].endsWith("%") ? Number(m[4].slice(0, -1)) / 100 : Number(m[4]);
}
if ([r, g, b, a].some((n) => !Number.isFinite(n))) return null;
// A fully transparent color paints nothing, whatever its channels say.
if (a === 0) return null;
const lin = (c: number) => { const x = c / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
/**
* Above this, a background is a sheet the message is laid on rather than a
* thing drawn on top of it. White wrappers sit at 1.0; the blue of a call to
* action lands near 0.09, mid-gray near 0.22.
*/
export const LIGHT_SURFACE_LUMINANCE = 0.5;
/** The background an element declares itself, or null if it declares none we can read. */
function declaredLuminance(el: HTMLElement): number | null {
const declared = el.getAttribute("bgcolor") ?? el.style?.backgroundColor ?? "";
if (!declared) return null;
return relativeLuminance(declared);
}
/**
* Mark the surfaces that must survive being themed, and count them.
*
* The reader has asked for their palette on mail that brings its own, which
* cannot be done perfectly — this is the same bargain a dark-reader extension
* makes. What it can do is tell the two kinds of color apart: a **sheet** the
* design sits on, which is what reads as a bright card and is neutralized, and
* a **painted surface** — a button, a banner — which is kept whole so its
* label stays legible on it.
*
* Two attributes come out of this. `data-ihm-keep` is a painted surface, which
* keeps its own colors. `data-ihm-in-keep` is an element sitting on one with
* no background of its own, whose color is left alone so a white label on a
* blue button stays readable. One rule in EMAIL_BASE_CSS neutralizes
* everything else.
*
* The distinction that matters is that being *inside* a painted surface is not
* inherited past a sheet. A light table nested in a dark 600px card is still a
* sheet and is still neutralized — that is issue #310, where a dark campaign
* rendered with beige cards inside it because the exemption used to be
* `[data-ihm-keep] *` in CSS and could not see the difference. Paint resumes
* below it: a dark button inside that nested table is kept as usual.
*
* Nothing the sender wrote is removed, so turning the switch off puts the
* message back exactly as it was — and a color that arrived from a `<style>`
* block rather than an attribute is covered too, which is most of them in
* modern templates.
*/
export function markKeptSurfaces(root: ParentNode): number {
let kept = 0;
// An explicit stack rather than recursion: this walks untrusted mail, and
// deeply nested tables are exactly what old newsletter HTML is made of.
const stack: Array<{ el: HTMLElement; onPaint: boolean }> = [];
const push = (parent: ParentNode, onPaint: boolean) => {
for (const child of Array.from(parent.children)) {
stack.push({ el: child as HTMLElement, onPaint });
}
};
push(root, false);
while (stack.length) {
const { el, onPaint } = stack.pop()!;
const lum = declaredLuminance(el);
let childrenOnPaint = onPaint;
if (lum !== null && lum < LIGHT_SURFACE_LUMINANCE) {
// Painted: keep it whole, and anything on it inherits that protection.
el.setAttribute("data-ihm-keep", "");
kept++;
childrenOnPaint = true;
} else if (lum !== null) {
// A sheet, wherever it sits. Left unmarked so it neutralizes, and it
// ends the protection rather than passing it on.
childrenOnPaint = false;
} else if (onPaint) {
// No background of its own, sitting on paint: leave its color alone.
el.setAttribute("data-ihm-in-keep", "");
}
push(el, childrenOnPaint);
}
return kept;
}
/**
* Whether a message really has an HTML alternative to render.
*
* `htmlBody` is a *derived* list, not a filter: RFC 8621 §4.1.4 says a message
* with no HTML alternative still gets one, and it holds the text/plain part.
* Confirmed live against Stalwart 0.16.21 (2026-09-10) -- a plain-text mail
* comes back with `htmlBody` and `textBody` naming the same part, typed
* `text/plain`, while a real multipart/alternative names two different parts.
*
* So "is there a body value under htmlBody" is not the question; the part's own
* type is. Answering the first one sent every plain-text message down the HTML
* path, where the body is placed in `.ihm-email-root` under
* `white-space: normal` and every line break collapses -- hard-wrapped mail
* arrived as a single paragraph with the signature and the quoted reply run
* into the prose.
*/
export function hasHtmlAlternative(part: { type?: string } | undefined, value: string | undefined): boolean {
return /^text\/html\b/i.test(part?.type ?? "") && Boolean(value);
}
export const TEXT_EMAIL_CSS = `
:host { display:block; }
.ihm-text-root { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.5px; line-height:1.55; white-space: pre-wrap; overflow-wrap: anywhere; color: inherit; }
.ihm-text-root a { color: var(--link, #0f766e); }
.ihm-text-root .q1 { color: var(--q1,#2563eb); } .ihm-text-root .q2 { color: var(--q2,#16a34a); } .ihm-text-root .q3 { color: var(--q3,#9333ea); }
`;