Let the theme be forced onto mail that styles itself

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 <style> block are covered as well as inline ones, and
print still pins the tokens to ink on white.

The mock grew the message this is about: an outer wrapper on
bgcolor="#ffffff", a <style> block, a coloured button, a grey footer.
Without one, neither the bug nor the fix could be seen.

Verified in a browser against the mock: with only the first switch on the
card is still white; with both, the wrapper computes to transparent, body
text follows the theme, and the button keeps white-on-blue. Two surfaces
marked, which are the two the message paints.

Closes #290
This commit is contained in:
2026-09-06 15:58:25 -07:00
parent db7b103a08
commit 2464c9655f
16 changed files with 272 additions and 16 deletions
+35 -5
View File
@@ -189,13 +189,42 @@ function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string,
return e;
}
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
/*
* A marketing template of the shape #290 was reported against.
*
* Nothing in it is unusual — an outer 600px wrapper on `bgcolor="#ffffff"`, a
* `<style>` block, a coloured call to action, a grey footer — and that is the
* point. Every one of those is enough to make `htmlDeclaresColors` true, so a
* mock without one could not show what "apply the theme to messages too" does
* to the mail people actually receive: nothing at all.
*/
const STYLED_MARKETING_HTML = `<html><head><style>
a { color:#1155CC; text-decoration:underline }
.h { font-size:20px; color:#111111 }
</style></head><body style="margin:0;background-color:#f4f4f4">
<table width="100%" bgcolor="#f4f4f4" cellpadding="0" cellspacing="0"><tr><td align="center">
<table width="600" bgcolor="#ffffff" cellpadding="0" cellspacing="0" style="background-color:#ffffff">
<tr><td style="padding:24px"><p class="h">Your order is on its way</p>
<p style="color:#333333">Thanks for shopping with us. Your parcel left the warehouse this morning.</p>
<table cellpadding="0" cellspacing="0"><tr>
<td bgcolor="#1155CC" style="border-radius:4px;padding:12px 20px">
<a href="https://example.com/track" style="color:#FFFFFF;text-decoration:none">Track your parcel</a>
</td></tr></table>
<p style="color:#666666;font-size:12px">Order #4471 &middot; placed 2 September</p>
</td></tr>
<tr><td bgcolor="#222222" style="padding:16px;color:#dddddd;font-size:12px">
You are receiving this because you bought something. <a href="https://example.com/x" style="color:#88bbff">Unsubscribe</a>
</td></tr>
</table>
</td></tr></table></body></html>`;
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 = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag &amp; drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
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") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : 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", "[email protected]"], subject: "Your order is on its way", daysAgo: 0.3, mailbox: "inbox", html: true, styled: true });
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
/*
+79 -1
View File
@@ -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('<table bgcolor="#ffffff"><tr><td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td></tr></table>');
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('<div style="background-color:#111827">dark</div><div style="background:#f8f8ff">sheet</div>');
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('<p style="color:#333">text</p><a href="https://x.io">link</a>');
expect(markKeptSurfaces(d)).toBe(0);
});
it("leaves the sender's own markup alone, so the switch is reversible", () => {
const d = frag('<table><tr><td bgcolor="#1155CC" style="color:#fff">Buy</td></tr></table>');
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
+98
View File
@@ -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<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 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
* `<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;
for (const el of Array.from(root.querySelectorAll<HTMLElement>("*"))) {
const declared = el.getAttribute("bgcolor") ?? el.style?.backgroundColor ?? "";
if (!declared) continue;
const lum = relativeLuminance(declared);
if (lum === null || lum >= LIGHT_SURFACE_LUMINANCE) continue;
el.setAttribute("data-ihm-keep", "");
kept++;
}
return kept;
}
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; }
+2
View File
@@ -518,6 +518,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Labels in der Seitenleiste anzeigen",
"Collapse sidebar to icons": "Seitenleiste auf Symbole verkleinern",
"Apply the theme to messages too": "Design auch auf Nachrichten anwenden",
"Apply it even to mail that styles itself": "Auch auf Mails anwenden, die sich selbst gestalten",
"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.": "Fast jede Werbe- oder Beleg-Mail setzt irgendwo eine Farbe, deshalb lässt die Einstellung darüber nahezu alle davon auf einer weißen Karte. Mit dieser Option wird das Design über die Farben des Absenders gelegt: Hintergründe, auf denen die Nachricht liegt, entfallen, während Schaltflächen und farbige Banner erhalten bleiben, damit ihr Text lesbar bleibt. Manche Mail übersteht das nicht unbeschadet deshalb ist es eine eigene Einstellung.",
"Swiping": "Wischgesten",
"Swipe left": "Nach links wischen",
"Swipe right": "Nach rechts wischen",
+2
View File
@@ -518,6 +518,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Mostrar las etiquetas en la barra lateral",
"Collapse sidebar to icons": "Reducir la barra lateral a iconos",
"Apply the theme to messages too": "Aplicar el tema también a los mensajes",
"Apply it even to mail that styles itself": "Aplicarlo incluso al correo que se da estilo propio",
"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.": "Casi todo el correo publicitario y de recibos define algún color, así que el ajuste anterior deja casi todo sobre una tarjeta blanca. Con esto activado, el tema se impone sobre los colores del remitente: se descartan los fondos sobre los que apoyó el mensaje, mientras que los botones y los banners de color se conservan para que su texto siga siendo legible. Algunos mensajes no sobrevivirán intactos, y por eso es un ajuste aparte.",
"Swiping": "Deslizamiento",
"Swipe left": "Deslizar a la izquierda",
"Swipe right": "Deslizar a la derecha",
+2
View File
@@ -524,6 +524,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Afficher les libellés dans la barre latérale",
"Collapse sidebar to icons": "Réduire la barre latérale en icônes",
"Apply the theme to messages too": "Appliquer le thème aux messages",
"Apply it even to mail that styles itself": "L'appliquer même aux messages qui se mettent en forme",
"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.": "Presque tous les courriers publicitaires et les reçus définissent une couleur quelque part, si bien que le réglage ci-dessus en laisse la quasi-totalité sur une carte blanche. Avec cette option, le thème est imposé par-dessus les couleurs de l'expéditeur : les fonds sur lesquels le message repose sont supprimés, tandis que les boutons et les bandeaux colorés sont conservés pour que leur texte reste lisible. Certains messages n'y survivront pas intacts, d'où un réglage distinct.",
"Swiping": "Balayage",
"Swipe left": "Balayer vers la gauche",
"Swipe right": "Balayer vers la droite",
+2
View File
@@ -518,6 +518,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "サイドバーにラベルを表示する",
"Collapse sidebar to icons": "サイドバーをアイコンだけにする",
"Apply the theme to messages too": "メールにもテーマを適用する",
"Apply it even to mail that styles itself": "自分で配色を持つメールにも適用する",
"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.": "宣伝メールや領収メールはほとんどがどこかで色を指定しているため、上の設定では大半が白いカードのままになります。これを有効にすると、送信者の配色の上からテーマを適用します。メッセージが載っている背景は取り除き、ボタンや色付きのバナーは文字が読めるようにそのまま残します。一部のメールは元の見た目を保てないため、別の設定として分けています。",
"Swiping": "スワイプ操作",
"Swipe left": "左へスワイプ",
"Swipe right": "右へスワイプ",
+2
View File
@@ -515,6 +515,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Labels in de zijbalk tonen",
"Collapse sidebar to icons": "Zijbalk inklappen tot pictogrammen",
"Apply the theme to messages too": "Thema ook op berichten toepassen",
"Apply it even to mail that styles itself": "Pas dit ook toe op e-mail met eigen vormgeving",
"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.": "Bijna alle reclame- en bonmail zet ergens een kleur, waardoor de instelling hierboven vrijwel alles op een witte kaart laat staan. Met deze optie wordt het thema over de kleuren van de afzender heen gelegd: achtergronden waarop het bericht is geplaatst vervallen, terwijl knoppen en gekleurde banners blijven staan zodat hun tekst leesbaar blijft. Sommige berichten overleven dat niet ongeschonden, en daarom is dit een aparte instelling.",
"Swiping": "Vegen",
"Swipe left": "Naar links vegen",
"Swipe right": "Naar rechts vegen",
+2
View File
@@ -521,6 +521,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Mostrar os marcadores na barra lateral",
"Collapse sidebar to icons": "Recolher a barra lateral em ícones",
"Apply the theme to messages too": "Aplicar o tema também às mensagens",
"Apply it even to mail that styles itself": "Aplicar mesmo em mensagens com estilo próprio",
"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.": "Quase toda mensagem de marketing ou de recibo define alguma cor, então a opção acima deixa quase todas em um cartão branco. Com isto ativado, o tema é imposto sobre as cores do remetente: os fundos sobre os quais a mensagem foi montada são descartados, enquanto botões e faixas coloridas são preservados para que o texto continue legível. Algumas mensagens não sobrevivem intactas, e por isso esta é uma opção separada.",
"Swiping": "Gestos de deslizar",
"Swipe left": "Deslizar para a esquerda",
"Swipe right": "Deslizar para a direita",
+2
View File
@@ -521,6 +521,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Показывать ярлыки на боковой панели",
"Collapse sidebar to icons": "Свернуть боковую панель до значков",
"Apply the theme to messages too": "Применять тему и к письмам",
"Apply it even to mail that styles itself": "Применять даже к письмам с собственным оформлением",
"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.": "Почти в каждом рекламном письме и чеке где-нибудь задан цвет, поэтому настройка выше оставляет почти все такие письма на белой карточке. С этой настройкой тема накладывается поверх цветов отправителя: фон, на котором свёрстано письмо, убирается, а кнопки и цветные плашки сохраняются, чтобы текст на них оставался читаемым. Некоторые письма это не переживут без потерь — поэтому настройка отдельная.",
"Swiping": "Жесты смахивания",
"Swipe left": "Смахнуть влево",
"Swipe right": "Смахнуть вправо",
+2
View File
@@ -515,6 +515,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "Показувати мітки на бічній панелі",
"Collapse sidebar to icons": "Згорнути бічну панель до значків",
"Apply the theme to messages too": "Застосовувати тему й до листів",
"Apply it even to mail that styles itself": "Застосовувати навіть до листів із власним оформленням",
"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.": "Майже в кожному рекламному листі та чеку десь задано колір, тому налаштування вище залишає майже всі такі листи на білій картці. Із цим налаштуванням тема накладається поверх кольорів відправника: тло, на якому зверстано лист, прибирається, а кнопки та кольорові плашки зберігаються, щоб текст на них залишався читабельним. Деякі листи цього не переживуть без втрат — тому це окреме налаштування.",
"Swiping": "Жести проведення",
"Swipe left": "Провести ліворуч",
"Swipe right": "Провести праворуч",
+2
View File
@@ -517,6 +517,8 @@ export const catalog: Catalog = {
"Show labels in the sidebar": "在侧边栏中显示标签",
"Collapse sidebar to icons": "将侧边栏收起为图标",
"Apply the theme to messages too": "邮件也应用主题",
"Apply it even to mail that styles itself": "即使邮件自带配色也套用",
"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.": "几乎所有营销邮件和收据邮件都会在某处设置颜色,因此上面的设置会让它们几乎全部停留在白色卡片上。启用此项后,主题会覆盖发件人的配色:邮件所依托的背景会被去掉,而按钮和彩色横幅会保留下来,使其文字仍然清晰可读。有些邮件无法完好呈现,因此这是一项单独的设置。",
"Swiping": "滑动手势",
"Swipe left": "向左滑动",
"Swipe right": "向右滑动",
+10
View File
@@ -130,6 +130,15 @@ export interface Settings {
imagePolicy: ImagePolicy;
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
themeMessageBody: boolean;
/**
* Extend that to mail which brings colours of its own.
*
* Only meaningful with `themeMessageBody` on. Off by default because it
* cannot be done perfectly: see `markKeptSurfaces` in lib/html.ts for the
* bargain it makes, and #290 for why the conservative default alone left
* essentially all HTML mail on a white card.
*/
themeStyledMessages: boolean;
undoSendSeconds: number;
composeFormat: ComposeFormat;
replyAllDefault: boolean;
@@ -307,6 +316,7 @@ export const DEFAULT_SETTINGS: Settings = {
knownSigners: {},
imagePolicy: "ask",
themeMessageBody: false,
themeStyledMessages: false,
undoSendSeconds: 8,
composeFormat: "html",
replyAllDefault: false,
+3
View File
@@ -618,6 +618,9 @@ img { max-width: 100%; }
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: var(--shadow-1); transition: transform .15s var(--ease); }
.switch[aria-checked="true"] { background: var(--accent); }
.switch[aria-checked="true"]::after { transform: translateX(18px); }
/* A switch that depends on another one above it. Without this it reads as an
ordinary "off", and clicking it does nothing with no explanation. */
.switch:disabled { opacity: .45; cursor: not-allowed; }
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.switch-row:last-child { border-bottom: 0; }
.switch-row .switch-text { display: flex; flex-direction: column; gap: 2px; }
+22 -10
View File
@@ -18,7 +18,7 @@ import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
import { spamReport, type SpamReport } from "@/lib/spamScore";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, domainOf, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, sanitizeEmailHtml } from "@/lib/html";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/html";
import { openableInTab, previewKind } from "@/lib/preview";
import { FilePreviewDialog } from "@/ui/filepreview";
import { findQuoteStart, textToHtml } from "@/lib/text";
@@ -138,6 +138,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const textRaw = textPart?.partId ? e.bodyValues?.[textPart.partId]?.value : undefined;
const showHtml = Boolean(htmlRaw);
const themeMessageBody = settings.themeMessageBody;
const themeStyledMessages = settings.themeStyledMessages;
// Inline images map
const cidMap = useMemo(() => {
@@ -158,12 +159,20 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
return null;
}, [expanded, showHtml, htmlRaw, cidMap, remoteAllowed, imageProxy]);
// Mail that paints itself keeps the light card it was designed for; the rest
// can follow the app theme when the user has asked for that.
const themed = useMemo(
() => themeMessageBody && Boolean(rendered) && !htmlDeclaresColors(rendered!.html, rendered!.bodyStyle),
[themeMessageBody, rendered],
/*
* Mail that paints itself keeps the light card it was designed for, unless
* the reader has asked for the theme over that too.
*
* `forced` is the second switch and is narrower than `themed`: it only turns
* on for mail that actually declares colours, so plain mail is themed the
* gentle way and never pays for the override rules.
*/
const declaresColors = useMemo(
() => Boolean(rendered) && htmlDeclaresColors(rendered!.html, rendered!.bodyStyle),
[rendered],
);
const themed = themeMessageBody && Boolean(rendered) && (!declaresColors || themeStyledMessages);
const forced = themed && declaresColors;
const attachments = useMemo(() => (e.attachments ?? []).filter((a) => !(a.cid && a.disposition === "inline" && a.type.startsWith("image/") && htmlRaw?.includes(`cid:${a.cid}`))), [e.attachments, htmlRaw]);
const icsPart = useMemo(() => findPart(e.bodyStructure, (p) => p.type === "text/calendar" || (p.name ?? "").toLowerCase().endsWith(".ics")), [e.bodyStructure]);
@@ -412,7 +421,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{icsPart && <InviteCard email={e} part={icsPart} />}
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
<div className="message-body">
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} onShowImages={showImages} onFollowLink={linkGuard} /> : <TextBody text={textRaw ?? ""} onFollowLink={linkGuard} />}
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} forced={forced} onShowImages={showImages} onFollowLink={linkGuard} /> : <TextBody text={textRaw ?? ""} onFollowLink={linkGuard} />}
</div>
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
{unsubscribe && (
@@ -490,7 +499,7 @@ function findPart(p: EmailBodyPart | undefined, pred: (p: EmailBodyPart) => bool
const QUOTE_SELECTORS = [".gmail_quote", "blockquote[type=cite]", ".moz-cite-prefix", "#divRplyFwdMsg", ".yahoo_quoted", "div[id^=appendonsend]", ".ms-outlook-mobile-reference-message", "#OLK_SRC_BODY_SECTION", ".protonmail_quote", ".ihm-quote"];
function HtmlBody({ html, bodyStyle, themed, onShowImages, onFollowLink }: { html: string; bodyStyle: string; themed: boolean; onFollowLink: ((href: string, text: string | null) => void) | null; onShowImages: () => void }) {
function HtmlBody({ html, bodyStyle, themed, forced, onShowImages, onFollowLink }: { html: string; bodyStyle: string; themed: boolean; forced: boolean; onFollowLink: ((href: string, text: string | null) => void) | null; onShowImages: () => void }) {
const hostRef = useRef<HTMLDivElement>(null);
const [hasQuote, setHasQuote] = useState(false);
const [quoteOpen, setQuoteOpen] = useState(false);
@@ -530,9 +539,12 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages, onFollowLink }: { htm
if (!host) return;
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
host.classList.toggle("themed", themed);
root.innerHTML = `<style>${EMAIL_BASE_CSS}</style><div class="ihm-email-root${themed ? " themed" : ""}" style="${bodyStyle.replace(/"/g, "'")}">${html}</div>`;
root.innerHTML = `<style>${EMAIL_BASE_CSS}</style><div class="ihm-email-root${themed ? " themed" : ""}${forced ? " forced" : ""}" style="${bodyStyle.replace(/"/g, "'")}">${html}</div>`;
// 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;
@@ -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.")}
/>
<Switch
checked={s.themeStyledMessages}
disabled={!s.themeMessageBody}
onChange={(v) => 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.")}
/>
<h2>{translate("Accent color")}</h2>
<div className="swatches">