diff --git a/README.md b/README.md index b6292c9..2212254 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("
bold and italic
", "font-family:Arial")).toBe(false); + expect(htmlDeclaresColors('link')).toBe(false); + expect(htmlDeclaresColors('
x
')).toBe(false); + }); + + it("is true when the message paints itself", () => { + expect(htmlDeclaresColors('x')).toBe(true); + expect(htmlDeclaresColors('x')).toBe(true); + expect(htmlDeclaresColors('
x
')).toBe(true); + expect(htmlDeclaresColors('
x
')).toBe(true); + 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 7d8b0de..6e93d6b 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]); @@ -201,7 +209,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 && ( @@ -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"]; -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); @@ -291,7 +299,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; @@ -337,7 +346,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) => (