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;
+}