Let messages follow the app theme, at the user's choice

Messages render on a white card in every theme. That is deliberate for mail
that styles itself, but #4 points out the case it gets wrong: a message with
no styling of its own has nothing worth preserving, and flashing white at
someone reading in the dark is a real cost.

Appearance gains a switch under the theme cards, off by default so the
current behaviour is unchanged. With it on, HTML mail that declares no
colours follows the app theme; mail that sets a background or text colour
still gets the light card it was designed for, because half-darkening someone
else's design is worse than leaving it alone. Plain-text mail already
followed the theme and is untouched by the switch.

The themed palette is expressed in the app's own custom properties, which
cross the shadow boundary, so switching theme repaints open messages without
re-rendering them, and the accent-coloured link stays consistent. The host
element takes color-scheme: inherit so form controls and scrollbars inside a
message match too.

htmlDeclaresColors covers bgcolor attributes, <font color>, and colour or
background declarations in style attributes and <style> blocks, while
ignoring near-misses like border-color and ?color= in a URL.

Closes #4
This commit is contained in:
2026-08-23 13:34:24 -07:00
parent c8fab0dd81
commit c3cecf9916
6 changed files with 68 additions and 6 deletions
+1
View File
@@ -29,6 +29,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a
- Archive / delete / spam / star / mark read / move / labels (IMAP keywords with colours) with **Undo**
- **"Filter messages like this…"** from the message context menu: creates a Sieve rule pre-filled from the sender/list (target folders can be created on the fly), and can **apply it immediately to the existing messages in the folder** (evaluated client-side, actions applied via JMAP)
- Safe HTML rendering: DOMPurify sanitisation inside a Shadow DOM, **remote images blocked by default** with a per-sender allow-list and an optional **privacy image proxy** (like Gmail's)
- Messages sit on a light card by default, untouched as the sender designed them. *Appearance Apply the theme to messages too* lets them follow the app's light/dark theme instead — plain-text mail always does, and with the option on so does HTML mail that brings no colours of its own; mail that styles itself is still left alone
- Attachments: previews for images/PDF/text, download all, inline `cid:` images, `.eml` export, *Show original*, header viewer
- Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { sanitizeEmailHtml, sanitizeEditorHtml } from "../html";
import { htmlDeclaresColors, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
describe("sanitizeEmailHtml", () => {
it("removes scripts and event handlers", () => {
@@ -32,3 +32,21 @@ describe("sanitizeEmailHtml", () => {
expect(sanitizeEditorHtml("<b>x</b><script>1</script>")).toBe("<b>x</b>");
});
});
describe("htmlDeclaresColors", () => {
it("is false for mail that brings no colours", () => {
expect(htmlDeclaresColors("<p>Hi there</p>")).toBe(false);
expect(htmlDeclaresColors("<div><b>bold</b> and <i>italic</i></div>", "font-family:Arial")).toBe(false);
expect(htmlDeclaresColors('<a href="https://x.io/?color=red">link</a>')).toBe(false);
expect(htmlDeclaresColors('<div style="border-color: red">x</div>')).toBe(false);
});
it("is true when the message paints itself", () => {
expect(htmlDeclaresColors('<td bgcolor="#ffffff">x</td>')).toBe(true);
expect(htmlDeclaresColors('<font color="red">x</font>')).toBe(true);
expect(htmlDeclaresColors('<div style="color:#333">x</div>')).toBe(true);
expect(htmlDeclaresColors('<div style="background-color:#fff">x</div>')).toBe(true);
expect(htmlDeclaresColors("<style>p { color: red }</style><p>x</p>")).toBe(true);
expect(htmlDeclaresColors("<p>plain</p>", "background:#eee")).toBe(true);
});
});
+24
View File
@@ -150,6 +150,7 @@ export function sanitizeEditorHtml(input: string): string {
/** Base CSS injected into the shadow root that hosts HTML email. */
export const EMAIL_BASE_CSS = `
:host { display:block; color-scheme: light; }
:host(.themed) { color-scheme: inherit; }
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; }
.ihm-email-root img { max-width:100%; height:auto; }
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
@@ -159,8 +160,31 @@ export const EMAIL_BASE_CSS = `
.ihm-email-root a { color:#0f766e; }
.ihm-email-root * { max-width:100%; box-sizing:border-box; }
.ihm-email-root [style*="position:fixed"], .ihm-email-root [style*="position: fixed"] { position:static !important; }
/* "Follow the app theme" — only applied to mail that brings no colours of its
own. The custom properties are inherited from the host document, so a theme
switch repaints the message without re-rendering it. */
.ihm-email-root.themed { color: var(--fg, #1f2937); background: var(--bg-elev, #fff); }
.ihm-email-root.themed blockquote { border-left-color: var(--border-strong, #cbd5e1); color: var(--fg-muted, #475569); }
.ihm-email-root.themed a { color: var(--link, #0f766e); }
.ihm-email-root.themed hr { border-color: var(--border, #e3e7ec); }
.ihm-email-root.themed img[data-ihm-blocked] { background: var(--bg-sunken, #f1f5f9) repeating-linear-gradient(45deg, var(--bg-hover, #e2e8f0) 0 6px, transparent 6px 12px); border-color: var(--border-strong, #cbd5e1); }
`;
/**
* 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.
*/
export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
const haystack = `${bodyStyle} ${html}`;
return (
/\bbgcolor\s*=/i.test(haystack) ||
/<font[^>]*\bcolor\s*=/i.test(haystack) ||
/(?:^|[;"'\s{])(?:background(?:-color)?|color)\s*:/i.test(haystack)
);
}
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; }
+3
View File
@@ -26,6 +26,8 @@ export interface Settings {
pageSize: number;
markReadDelay: number; // seconds; -1 = never auto
imagePolicy: ImagePolicy;
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
themeMessageBody: boolean;
undoSendSeconds: number;
composeFormat: ComposeFormat;
replyAllDefault: boolean;
@@ -79,6 +81,7 @@ export const DEFAULT_SETTINGS: Settings = {
pageSize: 50,
markReadDelay: 0,
imagePolicy: "ask",
themeMessageBody: false,
undoSendSeconds: 8,
composeFormat: "html",
replyAllDefault: false,
+14 -5
View File
@@ -9,7 +9,7 @@ import { useContacts } from "@/store/contacts";
import { client } from "@/jmap/client";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, sanitizeEmailHtml } from "@/lib/html";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, htmlDeclaresColors, sanitizeEmailHtml } from "@/lib/html";
import { findQuoteStart, textToHtml } from "@/lib/text";
import { Avatar } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
@@ -51,6 +51,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
const htmlRaw = htmlPart?.partId ? e.bodyValues?.[htmlPart.partId]?.value : undefined;
const textRaw = textPart?.partId ? e.bodyValues?.[textPart.partId]?.value : undefined;
const showHtml = Boolean(htmlRaw);
const themeMessageBody = settings.themeMessageBody;
// Inline images map
const cidMap = useMemo(() => {
@@ -71,6 +72,13 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
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],
);
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]);
const vcfParts = useMemo(() => (e.attachments ?? []).filter((p) => p.type === "text/vcard" || p.type === "text/x-vcard" || (p.name ?? "").toLowerCase().endsWith(".vcf")), [e.attachments]);
@@ -202,7 +210,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
{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} onShowImages={() => setAllowRemote(true)} /> : <TextBody text={textRaw ?? ""} />}
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} themed={themed} onShowImages={() => setAllowRemote(true)} /> : <TextBody text={textRaw ?? ""} />}
</div>
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
{unsubscribe && (
@@ -258,7 +266,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, onShowImages }: { html: string; bodyStyle: string; onShowImages: () => void }) {
function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bodyStyle: string; themed: boolean; onShowImages: () => void }) {
const hostRef = useRef<HTMLDivElement>(null);
const [hasQuote, setHasQuote] = useState(false);
const [quoteOpen, setQuoteOpen] = useState(false);
@@ -294,7 +302,8 @@ function HtmlBody({ html, bodyStyle, onShowImages }: { html: string; bodyStyle:
const host = hostRef.current;
if (!host) return;
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
root.innerHTML = `<style>${EMAIL_BASE_CSS}</style><div class="ihm-email-root" style="${bodyStyle.replace(/"/g, "'")}">${html}</div>`;
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>`;
// Collapse quoted content
const container = root.querySelector(".ihm-email-root") as HTMLElement | null;
let found = false;
@@ -340,7 +349,7 @@ function HtmlBody({ html, bodyStyle, onShowImages }: { html: string; bodyStyle:
setQuoteOpen(false);
root.addEventListener("click", onClick);
return () => root.removeEventListener("click", onClick);
}, [html, bodyStyle, onClick]);
}, [html, bodyStyle, themed, onClick]);
useEffect(() => {
const root = hostRef.current?.shadowRoot;
@@ -26,6 +26,13 @@ export function AppearanceSettings() {
</button>
))}
</div>
<Switch
checked={s.themeMessageBody}
onChange={(v) => update({ themeMessageBody: v })}
label="Apply the theme to messages too"
hint="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."
/>
<h2>Accent color</h2>
<div className="swatches">
{ACCENTS.map((a) => (