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