From 2464c9655fedc0f8b94d301ff6aa1e0b706a25a8 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 6 Sep 2026 15:58:25 -0700 Subject: [PATCH] Let the theme be forced onto mail that styles itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appearance gained "Apply the theme to messages too" some time ago, and it themes an HTML message only when the message brings no colours of its own. That predicate is the right default and it almost never passes: one `color:#FFFFFF` on one button label opts a whole message out, so in real mail — receipts, shipping notices, anything from a template — the switch did nothing at all and the reader kept a bright white card on a dark UI. A second switch, off by default and only meaningful with the first on, forces the palette over the sender's colours. It cannot be done perfectly, which is why it is a separate, explicit choice: the same bargain a dark-reader extension makes. What it does is tell two kinds of colour apart. A *sheet* the design sits on — the white 600px wrapper — is neutralised, and a *painted surface* — a call to action, a footer banner — is kept whole so its label stays legible on it. Relative luminance decides, at 0.5: white wrappers sit at 1.0, a blue button near 0.09. Only the painted ones are marked, with data-ihm-keep, and one rule in EMAIL_BASE_CSS neutralises everything else. Nothing the sender wrote is removed, so the switch is reversible, colours arriving from a +
+ + + +

Your order is on its way

+

Thanks for shopping with us. Your parcel left the warehouse this morning.

+ +
+ Track your parcel +
+

Order #4471 · placed 2 September

+
+ You are receiving this because you bought something. Unsubscribe +
+
`; + +function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) { const id = `e${counter++}`; const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z"); const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`; const html = `

Hi,

This is a sample HTML message about “${o.subject}”. It was generated by the ihasmail mock server.

logo

Cheers,
${o.from[0]}

On Monday, someone wrote:
This is the quoted part of an earlier message. It should be collapsed by default.
`; const textBlob = putBlob(text, "text/plain"); - const htmlBlob = putBlob(html, "text/html"); + const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html"); const attachments: Obj[] = []; if (o.attach) { attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null }); @@ -215,10 +244,10 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null, subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "), textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], - htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [], + htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [], attachments, - bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: html, isEncodingProblem: false, isTruncated: false } } : {}) }, - bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] }, + bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) }, + bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] }, "header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? ", " : null, "header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null, // Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so @@ -244,6 +273,7 @@ for (let i = 0; i < 45; i++) { addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 }); } } +addEmail({ from: ["Shop Updates", "orders@example.com"], subject: "Your order is on its way", daysAgo: 0.3, mailbox: "inbox", html: true, styled: true }); addEmail({ from: ["Demo User", USER], to: "ada@example.org", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true }; /* diff --git a/web/src/lib/__tests__/html.test.ts b/web/src/lib/__tests__/html.test.ts index f386132..e010776 100644 --- a/web/src/lib/__tests__/html.test.ts +++ b/web/src/lib/__tests__/html.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { htmlDeclaresColors, sanitizeEditorHtml, sanitizeEmailHtml } from "../html"; +import { LIGHT_SURFACE_LUMINANCE, htmlDeclaresColors, markKeptSurfaces, relativeLuminance, sanitizeEditorHtml, sanitizeEmailHtml } from "../html"; describe("sanitizeEmailHtml", () => { it("removes scripts and event handlers", () => { @@ -51,6 +51,84 @@ describe("htmlDeclaresColors", () => { }); }); +/** + * Forcing the theme onto mail that styles itself — issue #290. + * + * The switch above it leaves nearly all HTML mail alone, because one colour + * 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: neutralise 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 colour it cannot read", () => { + // Not a failure: the caller treats null as "no deliberate surface", which + // is the safe way round — an unreadable colour 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 colour 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; + }; + + it("keeps a coloured 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('
Buy
'); + 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 marked itself; the CSS keeps it because it is inside + // something that is, which is what stops white-on-blue turning unreadable. + expect(d.querySelector("a")!.hasAttribute("data-ihm-keep")).toBe(false); + }); + + 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('
Buy
'); + 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 diff --git a/web/src/lib/html.ts b/web/src/lib/html.ts index 1359ace..1e43ee8 100644 --- a/web/src/lib/html.ts +++ b/web/src/lib/html.ts @@ -191,12 +191,28 @@ export const EMAIL_BASE_CSS = ` .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 coloured is neutralised except the surfaces + marked by markKeptSurfaces() and their contents, 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-keep] *) { color: inherit !important; background-color: transparent !important; } +.ihm-email-root.forced a:not([data-ihm-keep]):not([data-ihm-keep] *) { color: var(--link, #0f766e) !important; } `; /** * Does this message paint itself? Mail that sets a background or text colour * 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}`; @@ -207,6 +223,88 @@ export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean { ); } +/* ---------- forcing the theme onto mail that styles itself ---------- */ + +/** + * Relative luminance per WCAG 2.x, or `null` when the colour 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 = { + 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 colour 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-grey near 0.22. + */ +export const LIGHT_SURFACE_LUMINANCE = 0.5; + +/** + * 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 colour apart: a **sheet** the + * design sits on, which is what reads as a bright card and is neutralised, and + * a **painted surface** — a button, a banner — which is kept whole so its + * label stays legible on it. + * + * Only the second is marked, with `data-ihm-keep`, and one CSS rule in + * EMAIL_BASE_CSS neutralises everything that is not marked or inside something + * marked. Nothing the sender wrote is removed, so turning the switch off puts + * the message back exactly as it was — and a colour that arrived from a + * ``; + root.innerHTML = ``; // Collapse quoted content const container = root.querySelector(".ihm-email-root") as HTMLElement | null; + // Tell the sender's painted surfaces apart from the sheets they sit on, + // before anything below reshapes the tree. + if (forced && container) markKeptSurfaces(container); let found = false; if (container) { let q: Element | null = null; @@ -592,7 +604,7 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages, onFollowLink }: { htm * so a changing handler now costs a listener swap and nothing else. */ // eslint-disable-next-line react-hooks/exhaustive-deps - }, [html, bodyStyle, themed]); + }, [html, bodyStyle, themed, forced]); useEffect(() => { const root = hostRef.current?.shadowRoot; diff --git a/web/src/views/settings/AppearanceSettings.tsx b/web/src/views/settings/AppearanceSettings.tsx index d89826e..f63b43e 100644 --- a/web/src/views/settings/AppearanceSettings.tsx +++ b/web/src/views/settings/AppearanceSettings.tsx @@ -91,6 +91,13 @@ export function AppearanceSettings() { label={translate("Apply the theme to messages too")} hint={translate("Plain-text mail already follows the theme. With this on, HTML mail that brings no colours of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them.")} /> + update({ themeStyledMessages: v })} + label={translate("Apply it even to mail that styles itself")} + hint={translate("Most marketing and receipt mail sets a colour somewhere, so the setting above leaves nearly all of it on a white card. With this on, the theme is forced over the sender's own colours: backgrounds they laid the message on are dropped, while buttons and coloured banners are kept so their text stays readable. Some mail will not survive it intact, which is why it is separate.")} + />

{translate("Accent color")}