From c3cecf991617bb446b6a59db91babb3cee45eff7 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 23 Aug 2026 13:34:16 -0700 Subject: [PATCH] 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, , and colour or background declarations in style attributes and

x

")).toBe(true); + expect(htmlDeclaresColors("

plain

", "background:#eee")).toBe(true); + }); +}); diff --git a/web/src/lib/html.ts b/web/src/lib/html.ts index dc0a4b7..181a023 100644 --- a/web/src/lib/html.ts +++ b/web/src/lib/html.ts @@ -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) || + /]*\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; } diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index eb02b24..1c492be 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -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, diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 78313a3..b64e94d 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -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 && } {vcfParts.map((p) => )}
- {showHtml && rendered ? setAllowRemote(true)} /> : } + {showHtml && rendered ? setAllowRemote(true)} /> : }
{attachments.length > 0 && } {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(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 = ``; + host.classList.toggle("themed", themed); + root.innerHTML = ``; // 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; diff --git a/web/src/views/settings/AppearanceSettings.tsx b/web/src/views/settings/AppearanceSettings.tsx index c15df7a..0343865 100644 --- a/web/src/views/settings/AppearanceSettings.tsx +++ b/web/src/views/settings/AppearanceSettings.tsx @@ -26,6 +26,13 @@ export function AppearanceSettings() { ))} + 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." + /> +

Accent color

{ACCENTS.map((a) => (