diff --git a/README.md b/README.md index 9fab876..bdf61ad 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/web/src/lib/__tests__/html.test.ts b/web/src/lib/__tests__/html.test.ts index 3892dcd..e40af30 100644 --- a/web/src/lib/__tests__/html.test.ts +++ b/web/src/lib/__tests__/html.test.ts @@ -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("x")).toBe("x"); }); }); + +describe("htmlDeclaresColors", () => { + it("is false for mail that brings no colours", () => { + expect(htmlDeclaresColors("
Hi there
")).toBe(false); + expect(htmlDeclaresColors("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 &&