Reading a message fetches its remote images through this server, so the sender learns nothing about the reader. Quoting the same message into a reply fetched them directly: same pixel, same reader, but the request carried their IP and user agent -- exactly what the proxy withholds. A quote now proxies them the way the message view does. That alone would be wrong, because a proxied URL belongs to this deployment: sent unchanged it would reach the recipient as images only this server can serve, broken for them and a beacon back here. So buildEmailObject turns them back into the addresses they came from, beside the pass that restores images blocked under pr411 and the one that turns editor blob URLs into cid: references. Deployments with the proxy off are unaffected: the quote fetches directly, as reading does there. Three tests from pr411 asserted the address sat in src when images were allowed, which was the old behaviour; they now ask whether the draft fetches it at all, proxied or not. No new strings.
493 lines
22 KiB
TypeScript
493 lines
22 KiB
TypeScript
import DOMPurify from "dompurify";
|
|
import { BASE_PATH, 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;
|
|
/**
|
|
* Drop `<style>` blocks. For HTML headed into the composer, which lives in
|
|
* the app document rather than a shadow root, so a sender's stylesheet
|
|
* would style the whole app.
|
|
*/
|
|
dropStyleBlocks?: boolean;
|
|
}
|
|
|
|
export interface SanitizeResult {
|
|
html: string;
|
|
remoteCount: number;
|
|
bodyStyle: string;
|
|
}
|
|
|
|
const REMOTE_URL_RE = /^(https?:)?\/\//i;
|
|
|
|
let hooked = false;
|
|
function ensureHooks() {
|
|
if (hooked) return;
|
|
hooked = true;
|
|
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
|
// An image map's <area> is a link too, and must not be able to navigate the app's tab.
|
|
if (node.tagName === "A" || node.tagName === "AREA") {
|
|
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");
|
|
}
|
|
|
|
/*
|
|
* Mail CSS is rewritten as text, so one rule holds throughout: nothing is ever
|
|
* cut out of it. Deleting a substring joins what was either side of it, and a
|
|
* sender can arrange for the join to spell `</style>` -- which is how an
|
|
* `@import` strip that ran after DOMPurify let markup out of a style block.
|
|
* Everything below replaces in place instead, and `<` is escaped last, so
|
|
* whatever the text says, it cannot close its element.
|
|
*/
|
|
|
|
const IDENT_CHAR = /[\w\-\u0080-\uFFFF]/;
|
|
|
|
/**
|
|
* Decode escapes that stand for letters or `-`, and write every other hex
|
|
* escape in the form that always ends with one space.
|
|
*
|
|
* `\75rl(` is a `url(` to a browser, and `position:\66ixed` is fixed, so the
|
|
* checks below have to see the letters. Decoding only letters keeps the
|
|
* meaning: an escaped letter is that letter in an identifier or a string
|
|
* alike. The canonical space stops a decoded letter being read as more hex
|
|
* digits of the escape before it (`\31\61` would otherwise become `\31a`).
|
|
*/
|
|
function decodeCssLetters(css: string): string {
|
|
return css.replace(/\\(?:([0-9a-fA-F]{1,6})(?:\r\n|[ \t\n\r\f])?|([^0-9a-fA-F\n\r\f]))/g, (m, hex: string | undefined, ch: string | undefined) => {
|
|
if (hex !== undefined) {
|
|
const cp = parseInt(hex, 16);
|
|
const c = cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : "";
|
|
return /^[A-Za-z-]$/.test(c) ? c : `\\${hex} `;
|
|
}
|
|
return /^[A-Za-z-]$/.test(ch!) ? ch! : m;
|
|
});
|
|
}
|
|
|
|
/** Functions that load an image from a bare string, with no url() to rewrite. */
|
|
const STRING_IMAGE_FN = /(?<![\w\-\\\u0080-\uFFFF])(-webkit-image-set|image-set|-webkit-cross-fade|cross-fade|image|src)(\s*\()/gi;
|
|
|
|
/**
|
|
* Rewrite every `url(...)` through `rewrite`, or return null when one cannot
|
|
* be parsed, in which case the caller drops the CSS rather than guess.
|
|
*/
|
|
function rewriteCssUrls(css: string, rewrite: (url: string) => string | null): string | null {
|
|
const re = /url\(/gi;
|
|
let out = "";
|
|
let last = 0;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(css))) {
|
|
if (m.index > 0 && IDENT_CHAR.test(css[m.index - 1]!)) continue;
|
|
let i = m.index + 4;
|
|
while (i < css.length && /\s/.test(css[i]!)) i++;
|
|
let value = "";
|
|
const q = css[i];
|
|
if (q === '"' || q === "'") {
|
|
i++;
|
|
for (;;) {
|
|
if (i >= css.length || css[i] === "\n") return null;
|
|
if (css[i] === "\\") { value += css.slice(i, i + 2); i += 2; continue; }
|
|
if (css[i] === q) { i++; break; }
|
|
value += css[i++];
|
|
}
|
|
while (i < css.length && /\s/.test(css[i]!)) i++;
|
|
if (css[i] !== ")") return null;
|
|
} else {
|
|
const end = css.indexOf(")", i);
|
|
if (end < 0) return null;
|
|
value = css.slice(i, end).trim();
|
|
if (/["'(\s]/.test(value)) return null;
|
|
i = end;
|
|
}
|
|
// A backslash in a URL is an escape we would have to decode to judge; no
|
|
// image mail really needs one, so it is simply not loaded.
|
|
const r = value.includes("\\") ? null : rewrite(value);
|
|
out += css.slice(last, m.index) + (r ? `url("${r.replace(/[\\"]/g, (c) => (c === '"' ? "\\22 " : "\\5c ")).replace(/[\r\n\f]/g, "")}")` : "none");
|
|
last = i + 1;
|
|
re.lastIndex = last;
|
|
}
|
|
return out + css.slice(last);
|
|
}
|
|
|
|
/**
|
|
* Make mail CSS safe to place in the page: urls rewritten, imports and
|
|
* string-image functions disabled, positioning hardened, and `<` escaped.
|
|
* Null means the CSS could not be read and should be dropped whole.
|
|
*/
|
|
function sanitizeCss(css: string, rewrite: (url: string) => string | null): string | null {
|
|
let s = decodeCssLetters(css).replace(/\/\*[\s\S]*?(\*\/|$)/g, " ");
|
|
const urls = rewriteCssUrls(s, rewrite);
|
|
if (urls === null) return null;
|
|
s = urls
|
|
// Renamed rather than removed: an unknown at-rule or function is dropped
|
|
// by the browser, and a rename cannot join anything together.
|
|
.replace(/@import/gi, "@ihm-blocked-import")
|
|
.replace(STRING_IMAGE_FN, "ihm-blocked$2");
|
|
return hardenCss(s).replace(/</g, "\\3c ");
|
|
}
|
|
|
|
export function proxiedImageUrl(url: string): string {
|
|
return withBase(`/api/image?url=${encodeURIComponent(url)}`);
|
|
}
|
|
|
|
/**
|
|
* The address a proxied image really points at, or null if this is not one.
|
|
*
|
|
* A proxied URL is this server's, so it is right for reading a message and
|
|
* wrong for sending one: a quote left this way would hand the recipient
|
|
* images that only load from inside this deployment (#412).
|
|
*/
|
|
export function unproxiedImageUrl(src: string): string | null {
|
|
const path = `${BASE_PATH}/api/image?url=`;
|
|
if (!src.startsWith(path)) return null;
|
|
try {
|
|
return decodeURIComponent(src.slice(path.length)) || null;
|
|
} catch {
|
|
return null; // Malformed escape: leave it alone rather than mangle it.
|
|
}
|
|
}
|
|
|
|
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 in style attributes and <style> blocks
|
|
const cssUrl = (u: string): string | null => {
|
|
const r = rewriteUrl(u);
|
|
return r.keep ? r.url : null;
|
|
};
|
|
clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
|
|
const s = el.getAttribute("style");
|
|
if (!s) return;
|
|
const out = sanitizeCss(s, cssUrl);
|
|
if (out === null) el.removeAttribute("style");
|
|
else if (out !== s) el.setAttribute("style", out);
|
|
});
|
|
clean.querySelectorAll("style").forEach((st) => {
|
|
if (opts.dropStyleBlocks) {
|
|
st.remove();
|
|
return;
|
|
}
|
|
const css = st.textContent ?? "";
|
|
if (!css) return;
|
|
st.textContent = sanitizeCss(css, cssUrl) ?? "";
|
|
});
|
|
if (bodyStyle) bodyStyle = sanitizeCss(bodyStyle, cssUrl) ?? "";
|
|
|
|
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); }
|
|
`;
|