Appearance: option to force the theme onto HTML mail that styles itself #290

Open
opened 2026-09-06 12:49:24 +00:00 by taisau · 5 comments
taisau commented 2026-09-06 12:49:24 +00:00

Background

#4 and #7 added Appearance › "Apply the theme to messages too", which themes an HTML message only when htmlDeclaresColors() says the mail brings no colours of its own. That predicate is the right default — a fully styled document can't be re-themed reliably, and half-darkening someone else's design is worse than leaving it alone.

The problem: in real mail the predicate almost never passes

htmlDeclaresColors() (web/src/lib/html.ts) returns true if the message contains any of:

  • a bgcolor= attribute,
  • a <font color=…> tag,
  • any color: or background: / background-color: declaration anywhere — including a single inline style attribute on one element.

That is an extremely low bar for mail that is, from a design standpoint, unstyled. Modern transactional and marketing mail (Shopify/Klaviyo, Google, GitHub, PayPal, …) always carries dozens of declarations, so in practice the switch never fires except on the rarest hand-written HTML mail — and the reader is back to a bright white card on a dark UI for essentially all HTML mail.

Concrete example (v2026.9.3+pr272)

A Shopify/Klaviyo marketing mail received today:

  • 4 bgcolor attributes — including bgcolor="#ffffff" on the outer 600px wrapper table (the white card the reader actually sees), plus bgcolor="#1155CC" on a CTA button
  • 88 color: / background: declarations — including a <style> block (a { color:#FFF; text-decoration:underline } …)

The mail is otherwise a plain one-column template. With the switch on, the reader still gets a bright white sheet, because one color:#FFFFFF on the button label is enough to opt the whole message out of theming.

The request

A second, opt-in switch — e.g. "Apply the theme even to mail that styles itself" (default off; only meaningful when the first switch is on) — which forces the themed palette onto the message body regardless of what the sender did.

Keeping it as a separate explicit choice preserves the current conservative behaviour by default and gives people who prefer dark an "I accept imperfect rendering" escape hatch — the same contract dark-reader browser extensions make.

Ideas on behaviour (for discussion, not prescription)

  • A minimal version: override the message host's background/color plus the backgrounds of top-level layout containers, so the white card stops being white even where the rest of the sender's styling stands.
  • A more aggressive version could invert declared text colours (à la Dark Reader) with the usual exceptions — don't flip white-on-colour buttons, leave images alone. The shadow root's color-scheme handling may take part of the distance for free.
  • Print presumably stays white regardless (PR #189 already pins that).

Happy to test a candidate build against a mailbox full of real marketing mail and report what breaks.

Rebuilt from: GH Archive, notification email, session transcript.

## Background #4 and #7 added **Appearance › "Apply the theme to messages too"**, which themes an HTML message only when `htmlDeclaresColors()` says the mail brings no colours of its own. That predicate is the right *default* — a fully styled document can't be re-themed reliably, and half-darkening someone else's design is worse than leaving it alone. ## The problem: in real mail the predicate almost never passes `htmlDeclaresColors()` (`web/src/lib/html.ts`) returns true if the message contains **any** of: - a `bgcolor=` attribute, - a `<font color=…>` tag, - any `color:` or `background:` / `background-color:` declaration anywhere — including a single inline `style` attribute on one element. That is an extremely low bar for mail that is, from a design standpoint, unstyled. Modern transactional and marketing mail (Shopify/Klaviyo, Google, GitHub, PayPal, …) always carries dozens of declarations, so in practice the switch never fires except on the rarest hand-written HTML mail — and the reader is back to a bright white card on a dark UI for essentially *all* HTML mail. ### Concrete example (v2026.9.3+pr272) A Shopify/Klaviyo marketing mail received today: - **4 `bgcolor` attributes** — including `bgcolor="#ffffff"` on the outer 600px wrapper table (the white card the reader actually sees), plus `bgcolor="#1155CC"` on a CTA button - **88 `color:` / `background:` declarations** — including a `<style>` block (`a { color:#FFF; text-decoration:underline } …`) The mail is otherwise a plain one-column template. With the switch on, the reader still gets a bright white sheet, because one `color:#FFFFFF` on the button label is enough to opt the whole message out of theming. ## The request A second, opt-in switch — e.g. **"Apply the theme even to mail that styles itself"** (default off; only meaningful when the first switch is on) — which forces the themed palette onto the message body regardless of what the sender did. Keeping it as a separate explicit choice preserves the current conservative behaviour by default and gives people who prefer dark an "I accept imperfect rendering" escape hatch — the same contract dark-reader browser extensions make. ## Ideas on behaviour (for discussion, not prescription) - A minimal version: override the message host's `background`/`color` plus the backgrounds of top-level layout containers, so the white card stops being white even where the rest of the sender's styling stands. - A more aggressive version could invert declared text colours (à la Dark Reader) with the usual exceptions — don't flip white-on-colour buttons, leave images alone. The shadow root's `color-scheme` handling may take part of the distance for free. - Print presumably stays white regardless (PR #189 already pins that). Happy to test a candidate build against a mailbox full of real marketing mail and report what breaks. <sub>Rebuilt from: GH Archive, notification email, session transcript.</sub>
Owner

Done, on main in #294.

Your diagnosis was exactly right, and the first thing done here was to reproduce it rather than assume it. With Apply the theme to messages too on and nothing else changed, a shipping notice built the way you describe still rendered as a bright white card. One color:#FFFFFF on one button label is genuinely enough, and in real mail that is nearly everything.

What shipped. A second switch, Apply it even to mail that styles itself, off by default and only meaningful with the first one on. That keeps the conservative behaviour exactly as it was and makes the escape hatch an explicit choice, which is how you framed it.

How it decides. Rather than inverting colours, it separates two kinds of colour. A sheet the design is laid on, like the white 600px wrapper, is neutralised. A painted surface, like a call to action or a footer banner, is kept whole so its label stays legible on it. Relative luminance decides, at a threshold of 0.5: a white wrapper sits at 1.0, the #1155CC from your example near 0.09. Only painted surfaces get marked, and a single stylesheet rule neutralises everything that is not marked or inside something marked.

Three things follow from doing it that way. Nothing the sender wrote is removed, so the switch is reversible. Colour arriving from a <style> block is covered as well as inline colour, which matters because that is where most template colour lives. And print is untouched, since it already pins the palette tokens to ink on white.

What was deliberately not done. The more aggressive half of your suggestion, inverting declared text colours the way a dark-reader extension does. Keeping painted surfaces intact gets the legibility win without guessing at the sender

Done, on `main` in #294. Your diagnosis was exactly right, and the first thing done here was to reproduce it rather than assume it. With **Apply the theme to messages too** on and nothing else changed, a shipping notice built the way you describe still rendered as a bright white card. One `color:#FFFFFF` on one button label is genuinely enough, and in real mail that is nearly everything. **What shipped.** A second switch, **Apply it even to mail that styles itself**, off by default and only meaningful with the first one on. That keeps the conservative behaviour exactly as it was and makes the escape hatch an explicit choice, which is how you framed it. **How it decides.** Rather than inverting colours, it separates two kinds of colour. A *sheet* the design is laid on, like the white 600px wrapper, is neutralised. A *painted surface*, like a call to action or a footer banner, is kept whole so its label stays legible on it. Relative luminance decides, at a threshold of 0.5: a white wrapper sits at 1.0, the `#1155CC` from your example near 0.09. Only painted surfaces get marked, and a single stylesheet rule neutralises everything that is not marked or inside something marked. Three things follow from doing it that way. Nothing the sender wrote is removed, so the switch is reversible. Colour arriving from a `<style>` block is covered as well as inline colour, which matters because that is where most template colour lives. And print is untouched, since it already pins the palette tokens to ink on white. **What was deliberately not done.** The more aggressive half of your suggestion, inverting declared text colours the way a dark-reader extension does. Keeping painted surfaces intact gets the legibility win without guessing at the sender
taisau commented 2026-09-08 23:45:48 +00:00

Real-mail follow-up on the themeStyledMessages switch shipped in #294 (deployed here as v2026.9.6). First, the good news: against a Shopify/Klaviyo white-wrapper template (4 bgcolor attrs, 88 colour declarations incl. a #1155CC CTA), the new logic behaves exactly as described — the white wrapper neutralises, body text follows the theme, and the blue CTA keeps its white label. No regressions spotted.

One real template does still defeat it, and the shape of the failure is different from the one #294 was built around: light panels nested inside dark "painted" cards survive theming.

The specimen is a dark-design campaign (Klaviyo-generated) with no bgcolor attributes and layout-only <style> blocks — all colour is inline:

  • <body style="background-color: #e7e5e2"> page surround, neutralised correctly (lum ≈ 0.79 ≥ 0.5)
  • 600px content cards with background-color: #2b2b2b (lum ≈ 0.02 < 0.5) → marked data-ihm-keep
  • content tables with background-color: #e7e5e2 nested inside those dark cards

Because the neutraliser rule exempts kept surfaces and everything inside them —

.ihm-email-root.forced *:not([data-ihm-keep]):not([data-ihm-keep] *) { color: inherit !important; background-color: transparent !important; }

— the nested light tables are never touched. In this template 14 of 21 light panels survive and render as beige/near-white cards inside an otherwise themed dark message, which is what reads as "this email is still white".

The descendant exemption is exactly right for its intended case (a kept button/banner whose text label must stay legible); it just can't distinguish "text inside a small painted surface" from "a whole light sheet sitting inside a large painted one". The earlier specimen passed because its kept surfaces were small CTAs with nothing light nested inside.

Ideas if useful, roughly in order of how targeted they feel:

  1. Apply the same luminance test to descendants of kept surfaces: walk kept subtrees and neutralise (or re-mark) any descendant whose declared background is itself ≥ LIGHT_SURFACE_LUMINANCE. Keeps button labels intact; catches nested sheets.
  2. Only exempt a kept surface's own text, not its subtree — i.e. drop the [data-ihm-keep] * half of the selector and let the normal cascade handle descendants (kept element keeps inline bg/colour, children neutralise individually). Riskier for buttons whose label colour is set on the parent.
  3. Cap what counts as "painted" — e.g. skip marking elements above some area threshold, so a full 600px layout card is never treated as a banner.

Happy to test a patch against this specimen (and re-run the white-wrapper one as the regression).

Real-mail follow-up on the `themeStyledMessages` switch shipped in #294 (deployed here as v2026.9.6). First, the good news: against a Shopify/Klaviyo white-wrapper template (4 `bgcolor` attrs, 88 colour declarations incl. a `#1155CC` CTA), the new logic behaves exactly as described — the white wrapper neutralises, body text follows the theme, and the blue CTA keeps its white label. No regressions spotted. One real template does still defeat it, and the shape of the failure is different from the one #294 was built around: **light panels nested inside dark "painted" cards survive theming.** The specimen is a dark-design campaign (Klaviyo-generated) with no `bgcolor` attributes and layout-only `<style>` blocks — all colour is inline: - `<body style="background-color: #e7e5e2">` page surround, neutralised correctly (lum ≈ 0.79 ≥ 0.5) - 600px content cards with `background-color: #2b2b2b` (lum ≈ 0.02 < 0.5) → marked `data-ihm-keep` - content tables with `background-color: #e7e5e2` **nested inside** those dark cards Because the neutraliser rule exempts kept surfaces and everything inside them — ```css .ihm-email-root.forced *:not([data-ihm-keep]):not([data-ihm-keep] *) { color: inherit !important; background-color: transparent !important; } ``` — the nested light tables are never touched. In this template 14 of 21 light panels survive and render as beige/near-white cards inside an otherwise themed dark message, which is what reads as "this email is still white". The descendant exemption is exactly right for its intended case (a kept button/banner whose text label must stay legible); it just can't distinguish "text inside a small painted surface" from "a whole light sheet sitting inside a large painted one". The earlier specimen passed because its kept surfaces were small CTAs with nothing light nested inside. Ideas if useful, roughly in order of how targeted they feel: 1. **Apply the same luminance test to descendants of kept surfaces**: walk kept subtrees and neutralise (or re-mark) any descendant whose declared background is itself ≥ `LIGHT_SURFACE_LUMINANCE`. Keeps button labels intact; catches nested sheets. 2. **Only exempt a kept surface's own text, not its subtree** — i.e. drop the `[data-ihm-keep] *` half of the selector and let the normal cascade handle descendants (kept element keeps inline bg/colour, children neutralise individually). Riskier for buttons whose label colour is set on the parent. 3. **Cap what counts as "painted"** — e.g. skip marking elements above some area threshold, so a full 600px layout card is never treated as a banner. Happy to test a patch against this specimen (and re-run the white-wrapper one as the regression).
Owner

Thank you — that is a genuinely useful report, and the diagnosis holds. I
checked it against main before doing anything: the selector you quote is
verbatim what is on line 202 of web/src/lib/html.ts, LIGHT_SURFACE_LUMINANCE
is 0.5, and markKeptSurfaces() marks any element under that threshold with no
area cap and no second look at descendants. Your "14 of 21 survive" is what
those three lines predict, so this is not an edge case — it follows from the
code.

Tracking it in #310 rather than reopening this one, since it is a different
shape of failure from the white wrapper this issue was about, and a closed
thread is a poor place for it to live.

Going with your first suggestion. Your ordering was right, and for the reason
you gave: dropping the [data-ihm-keep] * half puts back exactly what #294
fixed — <td bgcolor="#1155CC" style="color:#fff"> with a nested <a> is
common enough that the existing test pins it — and an area threshold is a magic
number that misfires on both a legitimate hero banner and a small dark panel
with a light chip in it.

Taking you up on the offer to test: I will put a patch on a branch and ping you
here when it is ready for your specimen, with the white-wrapper template as the
regression.

Thank you — that is a genuinely useful report, and the diagnosis holds. I checked it against `main` before doing anything: the selector you quote is verbatim what is on line 202 of `web/src/lib/html.ts`, `LIGHT_SURFACE_LUMINANCE` is 0.5, and `markKeptSurfaces()` marks any element under that threshold with no area cap and no second look at descendants. Your "14 of 21 survive" is what those three lines predict, so this is not an edge case — it follows from the code. Tracking it in #310 rather than reopening this one, since it is a different shape of failure from the white wrapper this issue was about, and a closed thread is a poor place for it to live. Going with your first suggestion. Your ordering was right, and for the reason you gave: dropping the `[data-ihm-keep] *` half puts back exactly what #294 fixed — `<td bgcolor="#1155CC" style="color:#fff">` with a nested `<a>` is common enough that the existing test pins it — and an area threshold is a magic number that misfires on both a legitimate hero banner and a small dark panel with a light chip in it. Taking you up on the offer to test: I will put a patch on a branch and ping you here when it is ready for your specimen, with the white-wrapper template as the regression.
Owner

@taisau — patch is up in #311 (branch theme-nested-light-panels), tracked as
#310. Ready for your specimen whenever you have a moment.

Approach is your first suggestion: being inside a painted surface is no longer
inherited past a sheet. A nested light table ends the protection and is themed;
paint resumes below it, so a button inside one is still kept whole.

One thing your report saved me from, which is worth recording. My first set of
tests asserted on which data-ihm-* attributes got set — and passed against the
unfixed code. The bug was never in the marking, it was in the rule that reads
the marks, exactly as you diagnosed. The tests now lift the neutraliser selector
out of EMAIL_BASE_CSS and ask el.matches(), so they check what the reader
sees rather than how it is implemented. All four fail without the change.

The last of them reconstructs your shape — 21 light panels, 14 nested in dark
cards — and asserts none is left exempt while all seven cards stay painted. But
a reconstruction is not the specimen, which is why I would rather have your run
before merging. The white-wrapper template as the regression is exactly the
right pairing.

@taisau — patch is up in #311 (branch `theme-nested-light-panels`), tracked as #310. Ready for your specimen whenever you have a moment. Approach is your first suggestion: being inside a painted surface is no longer inherited past a sheet. A nested light table ends the protection and is themed; paint resumes below it, so a button inside one is still kept whole. One thing your report saved me from, which is worth recording. My first set of tests asserted on which `data-ihm-*` attributes got set — and passed against the unfixed code. The bug was never in the marking, it was in the rule that reads the marks, exactly as you diagnosed. The tests now lift the neutraliser selector out of `EMAIL_BASE_CSS` and ask `el.matches()`, so they check what the reader sees rather than how it is implemented. All four fail without the change. The last of them reconstructs your shape — 21 light panels, 14 nested in dark cards — and asserts none is left exempt while all seven cards stay painted. But a reconstruction is not the specimen, which is why I would rather have your run before merging. The white-wrapper template as the regression is exactly the right pairing.
Owner

@taisau — here is everything you need to run it yourself. Branch
theme-nested-light-panels, PR #311, CI green.

Fastest way: run your specimen through the pipeline, no server needed

markKeptSurfaces() and the neutraliser rule are pure functions over a DOM, so
your saved HTML can go straight through them without mail, Stalwart or a build.
Save this as web/src/lib/__tests__/specimen.local.test.ts:

import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
  EMAIL_BASE_CSS, LIGHT_SURFACE_LUMINANCE,
  markKeptSurfaces, relativeLuminance, sanitizeEmailHtml,
} from "../html";

const SPECIMEN = process.env.SPECIMEN ?? "";

// Lifted from the stylesheet rather than copied, so this cannot drift.
const NEUTRALISER = EMAIL_BASE_CSS.match(
  /\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/,
)![1]!.trim();

describe("specimen", () => {
  it("leaves no light surface exempt from theming", () => {
    const d = document.createElement("div");
    d.innerHTML = sanitizeEmailHtml(readFileSync(SPECIMEN, "utf8")).html;

    markKeptSurfaces(d);

    const light = Array.from(d.querySelectorAll<HTMLElement>("*")).filter((el) => {
      const declared = el.getAttribute("bgcolor") ?? el.style?.backgroundColor ?? "";
      const lum = relativeLuminance(declared);
      return lum !== null && lum >= LIGHT_SURFACE_LUMINANCE;
    });
    const exempt = light.filter((el) => !el.matches(NEUTRALISER));

    console.log(`light surfaces: ${light.length}   still exempt: ${exempt.length}`);
    for (const el of exempt) {
      console.log("   ", el.tagName, el.getAttribute("style") ?? el.getAttribute("bgcolor"));
    }
    expect(exempt).toHaveLength(0);
  });
});
git fetch origin theme-nested-light-panels && git checkout theme-nested-light-panels
npm ci
cd web && SPECIMEN=/path/to/your-specimen.html npx vitest run src/lib/__tests__/specimen.local.test.ts

It counts every surface that declares a background at or above the threshold and
asks whether the real rule reaches it, printing the tag and colour of any that
escape — so a partial fix gives you a list rather than a yes/no.

Worth doing on both files. git checkout main -- web/src/lib/html.ts puts the
old logic back if you want the before/after in one sitting.

A reconstruction of your shape here reports still exempt: 14 on main and
0 on the branch. Fourteen out of twenty-one is your number exactly, which is a
good sign the reconstruction matches — but it is still a reconstruction, which is
why your real file is the one that settles it.

In the app

npm ci && npm run dev          # against your own Stalwart

Settings → AppearanceTheme. One gotcha that will waste your time
otherwise: "Apply it even to mail that styles itself" is disabled until
"Apply the theme to messages too" is on
— the child switch greys out, it does
not explain itself.

What to look for:

  • Dark campaign — no beige or near-white panels left inside the dark cards.
    The cards stay dark; the content tables inside them take your theme.
  • Shopify/Klaviyo white wrapper (the regression) — unchanged from
    v2026.9.6: wrapper neutralises, body text follows the theme, #1155CC CTA
    keeps its white label. That last one is the thing this change could plausibly
    have broken, so it is the one worth staring at.

What changed, in one line

Being inside a painted surface is no longer inherited past a sheet. A nested
light table ends the protection and is themed; paint resumes below it, so a
button inside such a table is still kept whole.

@taisau — here is everything you need to run it yourself. Branch `theme-nested-light-panels`, PR #311, CI green. ## Fastest way: run your specimen through the pipeline, no server needed `markKeptSurfaces()` and the neutraliser rule are pure functions over a DOM, so your saved HTML can go straight through them without mail, Stalwart or a build. Save this as `web/src/lib/__tests__/specimen.local.test.ts`: ```ts import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { EMAIL_BASE_CSS, LIGHT_SURFACE_LUMINANCE, markKeptSurfaces, relativeLuminance, sanitizeEmailHtml, } from "../html"; const SPECIMEN = process.env.SPECIMEN ?? ""; // Lifted from the stylesheet rather than copied, so this cannot drift. const NEUTRALISER = EMAIL_BASE_CSS.match( /\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/, )![1]!.trim(); describe("specimen", () => { it("leaves no light surface exempt from theming", () => { const d = document.createElement("div"); d.innerHTML = sanitizeEmailHtml(readFileSync(SPECIMEN, "utf8")).html; markKeptSurfaces(d); const light = Array.from(d.querySelectorAll<HTMLElement>("*")).filter((el) => { const declared = el.getAttribute("bgcolor") ?? el.style?.backgroundColor ?? ""; const lum = relativeLuminance(declared); return lum !== null && lum >= LIGHT_SURFACE_LUMINANCE; }); const exempt = light.filter((el) => !el.matches(NEUTRALISER)); console.log(`light surfaces: ${light.length} still exempt: ${exempt.length}`); for (const el of exempt) { console.log(" ", el.tagName, el.getAttribute("style") ?? el.getAttribute("bgcolor")); } expect(exempt).toHaveLength(0); }); }); ``` ```bash git fetch origin theme-nested-light-panels && git checkout theme-nested-light-panels npm ci cd web && SPECIMEN=/path/to/your-specimen.html npx vitest run src/lib/__tests__/specimen.local.test.ts ``` It counts every surface that declares a background at or above the threshold and asks whether the real rule reaches it, printing the tag and colour of any that escape — so a partial fix gives you a list rather than a yes/no. Worth doing on both files. `git checkout main -- web/src/lib/html.ts` puts the old logic back if you want the before/after in one sitting. A reconstruction of your shape here reports **`still exempt: 14`** on `main` and `0` on the branch. Fourteen out of twenty-one is your number exactly, which is a good sign the reconstruction matches — but it is still a reconstruction, which is why your real file is the one that settles it. ## In the app ```bash npm ci && npm run dev # against your own Stalwart ``` Settings → **Appearance** → **Theme**. One gotcha that will waste your time otherwise: *"Apply it even to mail that styles itself"* is **disabled until "Apply the theme to messages too" is on** — the child switch greys out, it does not explain itself. What to look for: - **Dark campaign** — no beige or near-white panels left inside the dark cards. The cards stay dark; the content tables inside them take your theme. - **Shopify/Klaviyo white wrapper (the regression)** — unchanged from v2026.9.6: wrapper neutralises, body text follows the theme, `#1155CC` CTA keeps its white label. That last one is the thing this change could plausibly have broken, so it is the one worth staring at. ## What changed, in one line Being inside a painted surface is no longer inherited past a sheet. A nested light table ends the protection and is themed; paint resumes below it, so a button inside such a table is still kept whole.
This repo is archived. You cannot comment on issues.