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:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user