fix: show plain-text mail in the dark theme's own colours instead of a fixed black

This commit is contained in:
Linus Rath
2026-10-31 04:05:45 +01:00
parent 6f66fa065a
commit 43973608d6
4 changed files with 83 additions and 8 deletions
+9 -1
View File
@@ -688,6 +688,13 @@ export default function EmailBodyView({
// Sanitised and wrapped once per message, body and settings, and reused // Sanitised and wrapped once per message, body and settings, and reused
// when the same body mounts again (lib/email-body-document). // when the same body mounts again (lib/email-body-document).
const emptyLabel = t('email_viewer.no_body_content', '(No body content available)'); const emptyLabel = t('email_viewer.no_body_content', '(No body content available)');
// A plain-text body in a dark app theme takes the theme's colours, not a
// fixed #0a0a0a. Only while the app itself is dark: a single message
// switched to dark in a light app has no dark palette to borrow.
const plainDarkColors = React.useMemo(
() => (resolvedTheme === 'dark' ? { background: c.background, text: c.text, link: c.textLink } : undefined),
[resolvedTheme, c.background, c.text, c.textLink],
);
const prepared = React.useMemo(() => bodyDocument({ const prepared = React.useMemo(() => bodyDocument({
key: `${jmapAccountId ?? ''}|${email.id}`, key: `${jmapAccountId ?? ''}|${email.id}`,
rawHtml, rawHtml,
@@ -700,9 +707,10 @@ export default function EmailBodyView({
plainTextFont, plainTextFont,
quoteLabels, quoteLabels,
textFactor, textFactor,
plainDarkColors,
}), [ }), [
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark, jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
messageSpacing, plainTextFont, quoteLabels, textFactor, messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors,
]); ]);
const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]); const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]);
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { buildBodyDocument } from '../email-body-document';
import { BUILTIN_THEMES } from '../../theme/builtin-themes';
// A plain-text mail read in dark mode sat on a fixed #0a0a0a whatever the
// app's theme was (UX audit 2026-10, "Plain-text mail dark background").
const base = {
key: 'k', emptyLabel: '-', blockRemoteImages: false, cidMap: {}, rawHtml: null, text: 'hello',
messageSpacing: 'auto' as const, plainTextFont: 'sans' as const, quoteLabels: { show: 's', hide: 'h' },
};
describe('plain-text mail colours', () => {
it("takes the dark theme's ground, text and link colours", () => {
const nord = { background: '#2e3440', text: '#eceff4', link: '#88c0d0' };
const html = buildBodyDocument({ ...base, isDark: true, plainDarkColors: nord }).html;
expect(html).toContain('background: #2e3440;');
expect(html).toContain('color: #eceff4;');
expect(html).toContain('a { color: #88c0d0;');
expect(html).not.toContain('#0a0a0a');
});
it('keeps white in light mode and the old dark colours without a theme', () => {
const colors = { background: '#2e3440', text: '#eceff4', link: '#88c0d0' };
expect(buildBodyDocument({ ...base, isDark: false, plainDarkColors: colors }).html).toContain('background: #ffffff;');
expect(buildBodyDocument({ ...base, isDark: true }).html).toContain('background: #0a0a0a;');
});
it('cannot close the style sheet with a theme colour', () => {
const html = buildBodyDocument({
...base, isDark: true, plainDarkColors: { background: 'red;}</style><script>x()</script>', text: '#fff', link: '#00f' },
}).html;
expect(html).not.toContain('</style><script>');
});
it('is fed from the app theme by the body view', () => {
expect(BUILTIN_THEMES.length).toBeGreaterThan(0);
const view = fs.readFileSync(path.resolve(__dirname, '../../components/EmailBodyView.tsx'), 'utf8');
expect(view).toMatch(/background: c\.background, text: c\.text, link: c\.textLink/);
});
});
+10 -2
View File
@@ -6,7 +6,7 @@
import { import {
prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml,
type MessageSpacing, type PlainTextFont, type MessageSpacing, type PlainTextFont, type PlainTextColors,
} from './email-html'; } from './email-html';
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse'; import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
import { mailBaseFontPx } from './mail-reading'; import { mailBaseFontPx } from './mail-reading';
@@ -31,6 +31,8 @@ export interface BodyDocumentInput {
* body. An HTML body is scaled by an injected pass instead. Default 1. * body. An HTML body is scaled by an injected pass instead. Default 1.
*/ */
textFactor?: number; textFactor?: number;
/** The app theme's colours for a plain-text body in dark mode. */
plainDarkColors?: PlainTextColors;
} }
export interface BodyDocument { export interface BodyDocument {
@@ -61,7 +63,12 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
} }
const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels); const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels);
return { return {
html: wrapPlainTextEmail(safe, { isDark, font: input.plainTextFont, fontPx: mailBaseFontPx(input.textFactor ?? 1) }), html: wrapPlainTextEmail(safe, {
isDark,
font: input.plainTextFont,
fontPx: mailBaseFontPx(input.textFactor ?? 1),
darkColors: input.plainDarkColors,
}),
applyInversion: false, applyInversion: false,
hasNativeDark: false, hasNativeDark: false,
blockedExternal: false, blockedExternal: false,
@@ -90,6 +97,7 @@ export function bodyDocument(input: BodyDocumentInput): BodyDocument {
const key = JSON.stringify([ const key = JSON.stringify([
input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont, input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont,
input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, input.textFactor ?? 1, input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, input.textFactor ?? 1,
input.plainDarkColors ?? null,
]); ]);
const hit = documents.get(key); const hit = documents.get(key);
if (hit && sameSources(hit.input, input)) { if (hit && sameSources(hit.input, input)) {
+21 -5
View File
@@ -953,16 +953,32 @@ export type PlainTextFont = 'sans' | 'mono';
// WebView. Mirrors the webmail's plain-text render path: app font by default // WebView. Mirrors the webmail's plain-text render path: app font by default
// (monospace on request, #830), `white-space: pre-wrap`, native dark theme // (monospace on request, #830), `white-space: pre-wrap`, native dark theme
// (no filter inversion), and a dark-mode-appropriate link colour. // (no filter inversion), and a dark-mode-appropriate link colour.
/** The app theme's colours for a plain-text body read in dark mode. */
export interface PlainTextColors {
background: string;
text: string;
link: string;
}
/** A theme colour for a style sheet: nothing that could end the declaration or the element. */
function cssColor(value: string, fallback: string): string {
const cleaned = value.replace(/[<>{};\\]/g, '').trim();
return cleaned || fallback;
}
export function wrapPlainTextEmail( export function wrapPlainTextEmail(
innerHtml: string, innerHtml: string,
options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number } = {}, options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number; darkColors?: PlainTextColors } = {},
): string { ): string {
const { isDark = true, font = 'sans', fontPx = 14 } = options; const { isDark = true, font = 'sans', fontPx = 14, darkColors } = options;
const cleaned = stripDangerousTags(innerHtml); const cleaned = stripDangerousTags(innerHtml);
const bg = isDark ? '#0a0a0a' : '#ffffff'; // In dark mode the body takes the app theme's ground, text and link colours
const fg = isDark ? '#fafafa' : '#1a1a1a'; // when they are given (a built-in dark theme isn't #0a0a0a); light mode
const link = isDark ? '#60a5fa' : '#1a73e8'; // stays on white like HTML mail.
const bg = isDark ? cssColor(darkColors?.background ?? '', '#0a0a0a') : '#ffffff';
const fg = isDark ? cssColor(darkColors?.text ?? '', '#fafafa') : '#1a1a1a';
const link = isDark ? cssColor(darkColors?.link ?? '', '#60a5fa') : '#1a73e8';
const fontFamily = font === 'mono' const fontFamily = font === 'mono'
? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace' ? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace'
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; : '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';