import { describe, expect, it } from "vitest";
import { EMAIL_BASE_CSS, LIGHT_SURFACE_LUMINANCE, htmlDeclaresColors, markKeptSurfaces, relativeLuminance, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
describe("sanitizeEmailHtml", () => {
it("removes scripts and event handlers", () => {
const r = sanitizeEmailHtml('
hi
');
expect(r.html).not.toContain("script");
expect(r.html).not.toContain("onclick");
expect(r.html).not.toContain("iframe");
});
it("blocks remote images until allowed and maps cid", () => {
const src = '

x
';
const blocked = sanitizeEmailHtml(src, { cidMap: { "logo@x": "/api/blob/a/b/logo.png" } });
expect(blocked.remoteCount).toBe(2);
expect(blocked.html).toContain('data-ihm-blocked="1"');
expect(blocked.html).toContain("/api/blob/a/b/logo.png");
expect(blocked.html).not.toMatch(/src="https:\/\/t\.example/);
expect(blocked.html).not.toContain("url(https://t.example");
const allowed = sanitizeEmailHtml(src, { allowRemote: true, proxyRemote: true });
expect(allowed.html).toContain("/api/image?url=https%3A%2F%2Ft.example%2Fp.gif");
});
it("forces links to open in new tabs", () => {
const r = sanitizeEmailHtml('x');
expect(r.html).toContain('target="_blank"');
expect(r.html).toContain("noopener");
});
it("strips javascript: urls", () => {
const r = sanitizeEmailHtml('x');
expect(r.html).not.toContain("javascript:");
});
it("editor sanitizer keeps basic formatting", () => {
expect(sanitizeEditorHtml("x")).toBe("x");
});
});
describe("htmlDeclaresColors", () => {
it("is false for mail that brings no colors", () => {
expect(htmlDeclaresColors("Hi there
")).toBe(false);
expect(htmlDeclaresColors("bold and italic
", "font-family:Arial")).toBe(false);
expect(htmlDeclaresColors('link')).toBe(false);
expect(htmlDeclaresColors('x
')).toBe(false);
});
it("is true when the message paints itself", () => {
expect(htmlDeclaresColors('x | ')).toBe(true);
expect(htmlDeclaresColors('x')).toBe(true);
expect(htmlDeclaresColors('x
')).toBe(true);
expect(htmlDeclaresColors('x
')).toBe(true);
expect(htmlDeclaresColors("x
")).toBe(true);
expect(htmlDeclaresColors("plain
", "background:#eee")).toBe(true);
});
});
/**
* Forcing the theme onto mail that styles itself — issue #290.
*
* The switch above it leaves nearly all HTML mail alone, because one color
* anywhere opts a message out. What this half has to get right is telling a
* sheet the design sits on from a surface painted on top of it: neutralize the
* first and the white card goes away, keep the second and a button keeps a
* label you can still read.
*/
describe("relativeLuminance", () => {
it("reads the forms mail actually uses", () => {
expect(relativeLuminance("#ffffff")).toBeCloseTo(1, 5);
expect(relativeLuminance("#FFF")).toBeCloseTo(1, 5);
expect(relativeLuminance("#000000")).toBeCloseTo(0, 5);
expect(relativeLuminance("white")).toBeCloseTo(1, 5);
expect(relativeLuminance("rgb(255, 255, 255)")).toBeCloseTo(1, 5);
expect(relativeLuminance("rgba(255,255,255,0.5)")).toBeCloseTo(1, 5);
});
it("has nothing to say about a color it cannot read", () => {
// Not a failure: the caller treats null as "no deliberate surface", which
// is the safe way round — an unreadable color must not keep a white sheet.
expect(relativeLuminance("color-mix(in srgb, red, blue)")).toBeNull();
expect(relativeLuminance("var(--brand)")).toBeNull();
expect(relativeLuminance("")).toBeNull();
});
it("treats a fully transparent color as painting nothing", () => {
expect(relativeLuminance("rgba(0,0,0,0)")).toBeNull();
expect(relativeLuminance("transparent")).toBeNull();
});
it("puts a white wrapper above the threshold and a call to action below it", () => {
expect(relativeLuminance("#ffffff")!).toBeGreaterThanOrEqual(LIGHT_SURFACE_LUMINANCE);
expect(relativeLuminance("#1155CC")!).toBeLessThan(LIGHT_SURFACE_LUMINANCE);
});
});
describe("markKeptSurfaces", () => {
const frag = (html: string) => {
const d = document.createElement("div");
d.innerHTML = html;
return d;
};
/*
* Marking is only half of it — the other half is the rule in EMAIL_BASE_CSS
* that reads the marks, and #310 was a bug in that half rather than in the
* marking. So these assert what the reader actually sees: does the
* neutralizer hit this element? The selector is lifted out of the stylesheet
* rather than copied, so a test cannot quietly drift from the rule it checks.
*/
const NEUTRALIZER = (() => {
const m = EMAIL_BASE_CSS.match(
/\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/,
);
if (!m) throw new Error("could not find the neutralizer rule in EMAIL_BASE_CSS");
return m[1]!.trim();
})();
/** True when the theme is forced onto this element rather than leaving it alone. */
const neutralized = (el: Element) => el.matches(NEUTRALIZER);
it("keeps a colored button and drops the white sheet around it", () => {
// The shape reported in #290: a Shopify/Klaviyo template whose outer 600px
// wrapper carries bgcolor="#ffffff" and whose CTA carries bgcolor="#1155CC".
const d = frag('');
expect(markKeptSurfaces(d)).toBe(1);
expect(d.querySelector("table")!.hasAttribute("data-ihm-keep")).toBe(false);
expect(d.querySelector("td")!.hasAttribute("data-ihm-keep")).toBe(true);
// The label is not a painted surface itself. It is marked as sitting on
// one, which is what stops white-on-blue turning unreadable.
expect(d.querySelector("a")!.hasAttribute("data-ihm-keep")).toBe(false);
expect(d.querySelector("a")!.hasAttribute("data-ihm-in-keep")).toBe(true);
});
it("neutralizes a light panel nested inside a dark painted card", () => {
// The shape reported in #310: a dark Klaviyo campaign whose 600px cards
// are dark enough to be marked, with light content tables inside them.
// Those tables used to inherit the card's exemption and render as beige
// sheets in an otherwise themed message.
const d = frag(
'',
);
expect(markKeptSurfaces(d)).toBe(1);
const divs = Array.from(d.querySelectorAll("div"));
const surround = divs[0]!;
const card = divs[1]!;
const nested = d.querySelector("table")!;
// The page surround is a sheet and always was.
expect(surround.hasAttribute("data-ihm-keep")).toBe(false);
// The card is paint and stays paint.
expect(card.hasAttribute("data-ihm-keep")).toBe(true);
// The fix, stated the way the reader experiences it: the nested sheet is
// themed, and so is the copy inside it. Before #310 both were exempt for
// being descendants of the card.
expect(neutralized(nested)).toBe(true);
expect(neutralized(d.querySelector("td")!)).toBe(true);
// The card itself is still left alone, and the page surround still goes.
expect(neutralized(card)).toBe(false);
expect(neutralized(surround)).toBe(true);
});
it("still keeps a button that sits inside a nested light panel", () => {
// Paint resumes below a sheet, however deep it is: the fix must not cost
// a call to action its label just because a sheet came between it and the
// card it is on.
const d = frag(
'',
);
expect(markKeptSurfaces(d)).toBe(2);
expect(neutralized(d.querySelector("table")!)).toBe(true);
expect(neutralized(d.querySelector("td")!)).toBe(false);
// The label keeps its white, which is the thing #294 bought and this must
// not spend.
expect(neutralized(d.querySelector("a")!)).toBe(false);
});
it("leaves no light panel exempt across the whole reported specimen", () => {
// #310 as reported: a dark campaign with no bgcolor attributes, 21 light
// panels, 14 of them nested inside dark 600px cards. Those fourteen were
// the ones rendering as beige sheets.
let cards = "";
for (let i = 0; i < 7; i++) {
cards +=
'";
}
let loose = "";
for (let i = 0; i < 7; i++) {
loose += '';
}
const d = frag('' + cards + loose + "
");
const panels = Array.from(d.querySelectorAll("table"));
expect(panels.length).toBe(21);
expect(markKeptSurfaces(d)).toBe(7);
expect(panels.filter((p) => !neutralized(p))).toHaveLength(0);
});
it("reads an inline background as well as the attribute", () => {
const d = frag('dark
sheet
');
expect(markKeptSurfaces(d)).toBe(1);
expect(d.querySelectorAll("[data-ihm-keep]").length).toBe(1);
expect((d.querySelector("[data-ihm-keep]") as HTMLElement).textContent).toBe("dark");
});
it("marks nothing in mail that paints no backgrounds", () => {
const d = frag('text
link');
expect(markKeptSurfaces(d)).toBe(0);
});
it("leaves the sender's own markup alone, so the switch is reversible", () => {
const d = frag('');
markKeptSurfaces(d);
const td = d.querySelector("td")!;
expect(td.getAttribute("bgcolor")).toBe("#1155CC");
expect(td.style.color).toBe("rgb(255, 255, 255)");
});
});
/**
* A shadow root scopes selectors, not layout. Mail CSS saying `position:fixed`
* is still positioned against the viewport, so a sender could paint over the
* whole application — a ready-made phishing surface inside our own origin.
*
* The control that actually stops it is layout containment on an ancestor of
* the shadow host, which mail CSS has no selector for; that lives in app.css
* and is asserted at the bottom of this file, because jsdom does no layout and
* cannot prove it here. These cover the second line of defense.
*/
describe("mail CSS cannot climb out of its card", () => {
const render = (html: string) => sanitizeEmailHtml(html).html;
it("turns fixed and sticky positioning into static", () => {
const out = render(``);
expect(out).toContain("position:static");
expect(out).not.toMatch(/position\s*:\s*fixed/i);
});
it("does so in style attributes too, however they are spaced", () => {
expect(render(`x
`)).not.toMatch(/position\s*:\s*fixed/i);
expect(render(`x
`)).not.toMatch(/position\s*:\s*sticky/i);
});
it("defangs :host, which is how mail CSS would reach the host element", () => {
const out = render(``);
expect(out).not.toContain(":host");
expect(out).not.toMatch(/position\s*:\s*fixed/i);
});
it("leaves ordinary positioning alone", () => {
const out = render(``);
expect(out).toContain("position:relative");
expect(out).toContain("position:absolute");
});
it("still rewrites url() while hardening", () => {
const out = sanitizeEmailHtml(``, { allowRemote: true, proxyRemote: true }).html;
expect(out).toContain("position:static");
expect(out).toContain("/api/image?url=");
});
});
describe("mail CSS cannot smuggle markup or remote loads", () => {
const TRACKER = "/api/image?url=https%3A%2F%2Ftrk.example%2Fo";
// Parse the output the way the reading pane does, and look at what came out.
const parse = (html: string) => {
const host = document.createElement("div");
host.innerHTML = html;
return host;
};
it("cannot close its style block, however an @import strip would join", () => {
const payload = "hi
";
const out = sanitizeEmailHtml(payload).html;
const dom = parse(out);
expect(dom.querySelector("img")).toBeNull();
expect(dom.querySelectorAll("style")).toHaveLength(1);
expect(out).not.toMatch(/<\/style>\s*
{
const out = sanitizeEmailHtml("").html;
const css = parse(out).querySelector("style")!.textContent!;
expect(css).toContain("p{color:red}");
expect(css).toContain("content:'\\3c b'");
});
it("disables @import in every spelling, even with remote content allowed", () => {
for (const css of ['@import "https://t.example/a.css";', "@import url(https://t.example/a.css);", '@\\69mport "https://t.example/a.css";', '@IM\\PORT "https://t.example/a.css";']) {
const out = sanitizeEmailHtml(``, { allowRemote: true }).html;
expect(out, css).not.toMatch(/@import/i);
expect(out, css).toContain("@ihm-blocked-import");
}
});
it("blocks image-set and other string-image functions", () => {
const out = sanitizeEmailHtml(`x
y
`).html;
expect(out).not.toMatch(/image-set\(/i);
});
it("rewrites escaped and awkwardly quoted url()", () => {
const cases = [
`x
`,
`x
`,
``,
];
for (const html of cases) {
const r = sanitizeEmailHtml(html);
expect(r.html, html).not.toContain("trk.example");
expect(r.html, html).not.toContain("t.example");
}
});
it("drops relative urls, which would reach the app's own image proxy", () => {
const out = sanitizeEmailHtml(`x
`).html;
expect(out).not.toContain("/api/image");
});
it("drops a style attribute it cannot parse", () => {
const out = sanitizeEmailHtml(`x
`).html;
expect(out).not.toContain("t.example");
});
it("sees escaped fixed positioning and :host", () => {
const out = sanitizeEmailHtml(``).html;
expect(out).not.toMatch(/:host/i);
expect(out).not.toMatch(/fixed/i);
});
it("does not let decoded letters merge into the escape before them", () => {
const out = sanitizeEmailHtml("").html;
expect(out).toContain(".\\31 a{color:red}");
});
it("keeps non-letter escapes, such as CJK font names", () => {
const out = sanitizeEmailHtml(`x
`).html;
expect(out).toContain("\\5FAE \\8F6F \\96C5 \\9ED1 ");
});
it("sanitizes the body element's style too", () => {
const r = sanitizeEmailHtml(`x
`);
expect(r.bodyStyle).not.toMatch(/image-set\(/i);
expect(r.bodyStyle).not.toMatch(/fixed/i);
});
it("still maps cid and allowed remote images in CSS", () => {
const r = sanitizeEmailHtml(`x
`, { cidMap: { "bg@x": "/api/blob/a/b/bg.png" }, allowRemote: true, proxyRemote: true });
expect(r.html).toContain('url("/api/blob/a/b/bg.png")');
expect(r.html).toContain("/api/image?url=https%3A%2F%2Ft.example%2Fb.png");
expect(r.remoteCount).toBe(1);
});
it("drops style blocks for the composer", () => {
const out = sanitizeEmailHtml("", { dropStyleBlocks: true }).html;
expect(out).not.toContain("