Readable white-on-white mail; forwards carry the original's header

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 21:36:47 +02:00
co-authored by Claude Opus 5.5
parent 5bf8fc81f6
commit f44c7b9e7c
5 changed files with 155 additions and 3 deletions
+4 -2
View File
@@ -37,7 +37,8 @@ export interface ComposerInit {
html?: string; // initial body when reopening an existing draft html?: string; // initial body when reopening an existing draft
draftId?: string; // when reopening an existing draft draftId?: string; // when reopening an existing draft
attachmentIds?: string[]; // compose-attachment ids when reopening a 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). // quoted message body (fetched from the original message's sanitized HTML).
quote?: { attribution: string; messageId: string }; quote?: { attribution: string; messageId: string };
// A forward: the message whose attachments go along with it. // A forward: the message whose attachments go along with it.
@@ -472,7 +473,8 @@ export function Composer({
return ( return (
`<div><br></div>` + `<div><br></div>` +
`<div class="wmc-quoted">` + `<div class="wmc-quoted">` +
`<div>${escapeHtml(init.quote.attribution)}</div>` + // One line for a reply; the header block of a forward is several.
`<div>${escapeHtml(init.quote.attribution).replace(/\n/g, "<br>")}</div>` +
`<blockquote>${quoteHtml}</blockquote></div>` `<blockquote>${quoteHtml}</blockquote></div>`
); );
} }
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { fixInvisibleText } from "../../lib/readability";
import type { MessageEnvelope } from "../../lib/types"; import type { MessageEnvelope } from "../../lib/types";
import { Attachments } from "./Attachments"; import { Attachments } from "./Attachments";
@@ -130,6 +131,8 @@ export function MessageBody({
// overflow removes any phantom scrollbar left by sub-pixel/line-box rounding // overflow removes any phantom scrollbar left by sub-pixel/line-box rounding
// (horizontal stays scrollable for genuinely wide emails). // (horizontal stays scrollable for genuinely wide emails).
doc.documentElement.style.overflowY = "hidden"; 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); injectQuoteToggle(doc);
resize(); resize();
// Re-measure after the first paint settles (fonts/layout can shift the height // Re-measure after the first paint settles (fonts/layout can shift the height
+27 -1
View File
@@ -61,6 +61,32 @@ function attributionFor(msg: MessageEnvelope): string {
return `On ${date}, at ${time}, ${who} wrote:`; 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( function buildReplyInit(
subject: string, subject: string,
msg: MessageEnvelope, msg: MessageEnvelope,
@@ -76,7 +102,7 @@ function buildReplyInit(
replyToMessageId: msg.id, replyToMessageId: msg.id,
kind, kind,
subject: `Fwd: ${norm}`, subject: `Fwd: ${norm}`,
quote, quote: { attribution: forwardHeaderFor(msg, subject), messageId: msg.id },
forwardFrom: msg.id, forwardFrom: msg.id,
}; };
} }
+22
View File
@@ -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
});
});
+99
View File
@@ -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<Element, Rgba | null>();
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<HTMLElement>("*"))) {
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;
}