Merge pull request #7 from LINUXexpert-org/message-theme-option

Let messages follow the app theme, at the user's choice
This commit is contained in:
LINUXexpert.org
2026-08-23 13:36:38 -07:00
committed by GitHub
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** - 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) - **"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) - 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 - 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 - 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 - 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 { describe, expect, it } from "vitest";
import { sanitizeEmailHtml, sanitizeEditorHtml } from "../html"; import { htmlDeclaresColors, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
describe("sanitizeEmailHtml", () => { describe("sanitizeEmailHtml", () => {
it("removes scripts and event handlers", () => { it("removes scripts and event handlers", () => {
@@ -32,3 +32,21 @@ describe("sanitizeEmailHtml", () => {
expect(sanitizeEditorHtml("<b>x</b><script>1</script>")).toBe("<b>x</b>"); 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. */ /** Base CSS injected into the shadow root that hosts HTML email. */
export const EMAIL_BASE_CSS = ` export const EMAIL_BASE_CSS = `
:host { display:block; color-scheme: light; } :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 { 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 { 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; } .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 a { color:#0f766e; }
.ihm-email-root * { max-width:100%; box-sizing:border-box; } .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; } .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 = ` export const TEXT_EMAIL_CSS = `
:host { display:block; } :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; } .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; pageSize: number;
markReadDelay: number; // seconds; -1 = never auto markReadDelay: number; // seconds; -1 = never auto
imagePolicy: ImagePolicy; imagePolicy: ImagePolicy;
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
themeMessageBody: boolean;
undoSendSeconds: number; undoSendSeconds: number;
composeFormat: ComposeFormat; composeFormat: ComposeFormat;
replyAllDefault: boolean; replyAllDefault: boolean;
@@ -79,6 +81,7 @@ export const DEFAULT_SETTINGS: Settings = {
pageSize: 50, pageSize: 50,
markReadDelay: 0, markReadDelay: 0,
imagePolicy: "ask", imagePolicy: "ask",
themeMessageBody: false,
undoSendSeconds: 8, undoSendSeconds: 8,
composeFormat: "html", composeFormat: "html",
replyAllDefault: false, replyAllDefault: false,
+14 -5
View File
@@ -9,7 +9,7 @@ import { useContacts } from "@/store/contacts";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, formatAddress } from "@/lib/address"; 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 { findQuoteStart, textToHtml } from "@/lib/text";
import { Avatar } from "@/ui/misc"; import { Avatar } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; 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 htmlRaw = htmlPart?.partId ? e.bodyValues?.[htmlPart.partId]?.value : undefined;
const textRaw = textPart?.partId ? e.bodyValues?.[textPart.partId]?.value : undefined; const textRaw = textPart?.partId ? e.bodyValues?.[textPart.partId]?.value : undefined;
const showHtml = Boolean(htmlRaw); const showHtml = Boolean(htmlRaw);
const themeMessageBody = settings.themeMessageBody;
// Inline images map // Inline images map
const cidMap = useMemo(() => { const cidMap = useMemo(() => {
@@ -71,6 +72,13 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
return null; return null;
}, [expanded, showHtml, htmlRaw, cidMap, remoteAllowed, imageProxy]); }, [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 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 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]); const vcfParts = useMemo(() => (e.attachments ?? []).filter((p) => p.type === "text/vcard" || p.type === "text/x-vcard" || (p.name ?? "").toLowerCase().endsWith(".vcf")), [e.attachments]);
@@ -201,7 +209,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
{icsPart && <InviteCard email={e} part={icsPart} />} {icsPart && <InviteCard email={e} part={icsPart} />}
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)} {vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
<div className="message-body"> <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> </div>
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />} {attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
{unsubscribe && ( {unsubscribe && (
@@ -257,7 +265,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"]; 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 hostRef = useRef<HTMLDivElement>(null);
const [hasQuote, setHasQuote] = useState(false); const [hasQuote, setHasQuote] = useState(false);
const [quoteOpen, setQuoteOpen] = useState(false); const [quoteOpen, setQuoteOpen] = useState(false);
@@ -291,7 +299,8 @@ function HtmlBody({ html, bodyStyle, onShowImages }: { html: string; bodyStyle:
const host = hostRef.current; const host = hostRef.current;
if (!host) return; if (!host) return;
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" }); 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 // Collapse quoted content
const container = root.querySelector(".ihm-email-root") as HTMLElement | null; const container = root.querySelector(".ihm-email-root") as HTMLElement | null;
let found = false; let found = false;
@@ -337,7 +346,7 @@ function HtmlBody({ html, bodyStyle, onShowImages }: { html: string; bodyStyle:
setQuoteOpen(false); setQuoteOpen(false);
root.addEventListener("click", onClick); root.addEventListener("click", onClick);
return () => root.removeEventListener("click", onClick); return () => root.removeEventListener("click", onClick);
}, [html, bodyStyle, onClick]); }, [html, bodyStyle, themed, onClick]);
useEffect(() => { useEffect(() => {
const root = hostRef.current?.shadowRoot; const root = hostRef.current?.shadowRoot;
@@ -26,6 +26,13 @@ export function AppearanceSettings() {
</button> </button>
))} ))}
</div> </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> <h2>Accent color</h2>
<div className="swatches"> <div className="swatches">
{ACCENTS.map((a) => ( {ACCENTS.map((a) => (