From f44c7b9e7c3077a80c5d5da6e0f79dc18c4b0448 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Thu, 8 Oct 2026 21:36:47 +0200 Subject: [PATCH] Readable white-on-white mail; forwards carry the original's header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message written in a dark-mode mail app (or pasted from a dark page) can set all its text white with no background — invisible on the reading pane's white page. After a message renders, text whose colour is practically the colour behind it gets the pane's normal text colour, when a good part of the message (30% of its text) is invisible. Text hidden on purpose — a help desk's white ticket reference, a newsletter's hidden preview line — stays hidden; white on dark, coloured or pictured backgrounds is left as designed (lib/readability.ts, with tests). The check runs before the quote is folded. A forward quoted the original under the reply's one line ("On …, Name wrote:"), without the sender's address. It now opens with the usual block: Forwarded message, From (name and address), Date, Subject, To, Cc. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/compose/Composer.tsx | 6 +- .../src/components/thread/MessageBody.tsx | 3 + frontend/src/components/thread/ThreadView.tsx | 28 +++++- frontend/src/lib/readability.test.ts | 22 +++++ frontend/src/lib/readability.ts | 99 +++++++++++++++++++ 5 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/readability.test.ts create mode 100644 frontend/src/lib/readability.ts diff --git a/frontend/src/components/compose/Composer.tsx b/frontend/src/components/compose/Composer.tsx index cf00616..e4ec02f 100644 --- a/frontend/src/components/compose/Composer.tsx +++ b/frontend/src/components/compose/Composer.tsx @@ -37,7 +37,8 @@ export interface ComposerInit { html?: string; // initial body when reopening an existing draft draftId?: string; // when reopening an existing draft attachmentIds?: string[]; // compose-attachment ids when reopening a draft - // A reply/forward quotes the original: this attribution line sits above the + // A reply/forward quotes the original: this attribution (a forward: its header + // block, one line each) sits above the // quoted message body (fetched from the original message's sanitized HTML). quote?: { attribution: string; messageId: string }; // A forward: the message whose attachments go along with it. @@ -472,7 +473,8 @@ export function Composer({ return ( `

` + `
` + - `
${escapeHtml(init.quote.attribution)}
` + + // One line for a reply; the header block of a forward is several. + `
${escapeHtml(init.quote.attribution).replace(/\n/g, "
")}
` + `
${quoteHtml}
` ); } diff --git a/frontend/src/components/thread/MessageBody.tsx b/frontend/src/components/thread/MessageBody.tsx index 18577d0..7fa8844 100644 --- a/frontend/src/components/thread/MessageBody.tsx +++ b/frontend/src/components/thread/MessageBody.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useT } from "../../i18n"; import { api } from "../../lib/api"; +import { fixInvisibleText } from "../../lib/readability"; import type { MessageEnvelope } from "../../lib/types"; import { Attachments } from "./Attachments"; @@ -130,6 +131,8 @@ export function MessageBody({ // overflow removes any phantom scrollbar left by sub-pixel/line-box rounding // (horizontal stays scrollable for genuinely wide emails). doc.documentElement.style.overflowY = "hidden"; + // Before the quote is folded away: hidden text counts as hidden on purpose. + fixInvisibleText(doc); // white-on-white from a dark-mode sender (lib/readability.ts) injectQuoteToggle(doc); resize(); // Re-measure after the first paint settles (fonts/layout can shift the height diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx index a67fb4a..000005c 100644 --- a/frontend/src/components/thread/ThreadView.tsx +++ b/frontend/src/components/thread/ThreadView.tsx @@ -61,6 +61,32 @@ function attributionFor(msg: MessageEnvelope): string { return `On ${date}, at ${time}, ${who} wrote:`; } +/** The header block above a forwarded message, as mail programs write it: + * who sent it (name and address), when, its subject and recipients. */ +function forwardHeaderFor(msg: MessageEnvelope, subject: string): string { + const d = new Date(msg.date); + const date = d.toLocaleString(getDateLocale(), { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: getHour12(), + }); + const person = (a: { name: string; email: string }) => + a.name && a.name !== a.email ? `${a.name} <${a.email}>` : a.email; + const lines = [ + "---------- Forwarded message ---------", + `From: ${msg.from_addr.map(person).join(", ")}`, + `Date: ${date}`, + `Subject: ${msg.subject || subject}`, + ]; + if (msg.to_addr.length) lines.push(`To: ${msg.to_addr.map(person).join(", ")}`); + if (msg.cc_addr.length) lines.push(`Cc: ${msg.cc_addr.map(person).join(", ")}`); + return lines.join("\n"); +} + function buildReplyInit( subject: string, msg: MessageEnvelope, @@ -76,7 +102,7 @@ function buildReplyInit( replyToMessageId: msg.id, kind, subject: `Fwd: ${norm}`, - quote, + quote: { attribution: forwardHeaderFor(msg, subject), messageId: msg.id }, forwardFrom: msg.id, }; } diff --git a/frontend/src/lib/readability.test.ts b/frontend/src/lib/readability.test.ts new file mode 100644 index 0000000..ee30051 --- /dev/null +++ b/frontend/src/lib/readability.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { contrast, parseColor } from "./readability"; + +describe("readability", () => { + it("reads computed colours", () => { + expect(parseColor("rgb(26, 26, 26)")).toEqual([26, 26, 26, 1]); + expect(parseColor("rgba(255, 255, 255, 0.98)")).toEqual([255, 255, 255, 0.98]); + expect(parseColor("rgb(0 0 0 / 50%)")).toEqual([0, 0, 0, 0.5]); + expect(parseColor("transparent")).toBeNull(); + }); + + it("tells invisible text from readable text", () => { + const white = parseColor("rgb(255, 255, 255)")!; + const dark = parseColor("rgb(20, 24, 30)")!; + expect(contrast(parseColor("rgba(255, 255, 255, 0.98)")!, white)).toBeLessThan(1.5); // the bug + expect(contrast(parseColor("rgb(221, 221, 221)")!, white)).toBeLessThan(1.5); // light grey + expect(contrast(parseColor("rgb(26, 26, 26)")!, white)).toBeGreaterThan(15); + expect(contrast(white, dark)).toBeGreaterThan(15); // white on a dark newsletter: fine + expect(contrast(parseColor("rgb(136, 136, 136)")!, white)).toBeGreaterThan(3); // grey footer: kept + }); +}); diff --git a/frontend/src/lib/readability.ts b/frontend/src/lib/readability.ts new file mode 100644 index 0000000..c7e7cf2 --- /dev/null +++ b/frontend/src/lib/readability.ts @@ -0,0 +1,99 @@ +// Text a sender made invisible by accident: written in a dark-mode mail app (or +// pasted from a dark page), it carries an explicit white colour but no +// background, so on the reading pane's white page it is white on white. After a +// message renders, text whose colour is (nearly) the colour behind it gets the +// pane's normal text colour. Only such text changes: white on a dark +// background, on a coloured one, or over a picture is left as designed. +// +// Some mail hides a little text on purpose (a help desk's ticket reference, a +// newsletter's preview line): that stays hidden. Text is recoloured only when a +// good part of the message is invisible — the message itself is unreadable. + +type Rgba = [number, number, number, number]; + +/** The pane's own text colour (app/api/messages.py, the message frame's body). */ +const READABLE = "#1a1a1a"; +// Below this contrast ratio (1 = identical, 21 = black on white) text is +// unreadable: white on white is 1.0, light grey #ddd on white about 1.4. +const MIN_CONTRAST = 1.5; +// Share of the message's text that must be invisible before anything changes. +const MIN_SHARE = 0.3; + +/** A computed CSS colour ("rgb(…)" / "rgba(…)") as numbers, or null. */ +export function parseColor(value: string): Rgba | null { + const m = value.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,/\s]+([\d.]+%?))?\s*\)/i); + if (!m) return null; + let alpha = m[4] === undefined ? 1 : parseFloat(m[4]); + if (m[4]?.endsWith("%")) alpha /= 100; + return [parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3]), alpha]; +} + +function luminance([r, g, b]: Rgba): number { + const lin = (c: number) => { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +/** WCAG contrast ratio of ``fg`` (blended over ``bg`` when see-through) on ``bg``. */ +export function contrast(fg: Rgba, bg: Rgba): number { + const a = fg[3]; + const shown: Rgba = [ + fg[0] * a + bg[0] * (1 - a), + fg[1] * a + bg[1] * (1 - a), + fg[2] * a + bg[2] * (1 - a), + 1, + ]; + const [hi, lo] = [luminance(shown), luminance(bg)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const WHITE: Rgba = [255, 255, 255, 1]; + +/** Fix the invisible text in a rendered message document. */ +export function fixInvisibleText(doc: Document): number { + const win = doc.defaultView; + if (!win || !doc.body) return 0; + // What is behind an element: the first solid background up its ancestors, + // the page's white if none, or null when a picture is involved (unknown). + const behind = new Map(); + const backgroundOf = (el: Element | null): Rgba | null => { + if (!el) return WHITE; + const known = behind.get(el); + if (known !== undefined) return known; + const cs = win.getComputedStyle(el); + let result: Rgba | null; + if (cs.backgroundImage && cs.backgroundImage !== "none") { + result = null; + } else { + const bg = parseColor(cs.backgroundColor); + result = bg && bg[3] >= 0.5 ? bg : backgroundOf(el.parentElement); + } + behind.set(el, result); + return result; + }; + const invisible: HTMLElement[] = []; + let invisibleChars = 0; + let allChars = 0; + for (const el of Array.from(doc.body.querySelectorAll("*"))) { + const chars = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .reduce((sum, n) => sum + (n.textContent ?? "").trim().length, 0); + if (chars === 0) continue; + const cs = win.getComputedStyle(el); + // Not shown at all, or shrunk to nothing: hidden on purpose. + if (el.getClientRects().length === 0 || parseFloat(cs.fontSize) < 4 || cs.opacity === "0") continue; + allChars += chars; + const fg = parseColor(cs.color); + const bg = backgroundOf(el); + if (!fg || !bg || fg[3] < 0.2) continue; + if (contrast(fg, bg) < MIN_CONTRAST) { + invisible.push(el); + invisibleChars += chars; + } + } + if (invisible.length === 0 || invisibleChars < MIN_SHARE * allChars) return 0; + for (const el of invisible) el.style.setProperty("color", READABLE, "important"); + return invisible.length; +}