feat: show message bodies at the chosen text size and enlarge text below the minimum

This commit is contained in:
Linus Rath
2026-10-29 17:53:55 +01:00
parent b63038a644
commit c07ba1a370
7 changed files with 304 additions and 9 deletions
@@ -70,3 +70,12 @@ describe('conversation controls name their target', () => {
expect(screen.getByRole('button', { name: /^email_viewer\.(download|preview)_named \{"name":"report\.pdf"\}$/ })).toBeInTheDocument();
});
});
describe('conversation message text size', () => {
it('plain text follows the mail text size instead of a fixed 13px', () => {
useSettingsStore.setState({ mailTextScale: 2 } as never);
show([mail('a', { htmlBody: [], textBody: [{ partId: '1', type: 'text/plain' }], bodyValues: { '1': { value: 'just text' } } })]);
const body = screen.getByText('just text').closest('[data-plugin-source="email-body"]') as HTMLElement;
expect(body.style.fontSize).toMatch(/0\.8125rem \* 2|1\.625rem/);
});
});
@@ -105,3 +105,26 @@ describe('attachment actions', () => {
expect(screen.getAllByRole('button', { name: 'download_named {"name":"report.pdf"}' }).length).toBeGreaterThan(0);
});
});
describe('message text size', () => {
it('plain text follows the mail text size and its minimum instead of a fixed 14px', async () => {
useSettingsStore.setState({ mailTextScale: 1.5, mailMinFontSize: 16 } as never);
await show(message({ text: 'Plain words here' }));
const body = screen.getByText('Plain words here').closest('.email-content-text') as HTMLElement;
// jsdom folds the calc().
expect(body.style.fontSize).toMatch(/^max\(.*(0\.875rem \* 1\.5|1\.3125rem).*, 16px\)$/);
});
it('plain text follows the app text size by default', async () => {
useSettingsStore.setState({ fontSize: 'xx-large' } as never);
await show(message({ text: 'Plain words here' }));
const body = screen.getByText('Plain words here').closest('.email-content-text') as HTMLElement;
expect(body.style.fontSize).toBe('0.875rem');
});
it('rebuilds the HTML frame for a new size', async () => {
useSettingsStore.setState({ fontSize: 'xx-large' } as never);
const { container } = await show(message({ html: '<p>Some <b>formatted</b> text</p>' }));
expect(container.querySelector('iframe')!.getAttribute('srcdoc')).toContain('data-reading="1.5/0"');
});
});
+16 -4
View File
@@ -7,6 +7,8 @@ import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { mailLanguage } from "@/lib/mail-language";
import { applyMailTextSize } from "@/lib/mail-reading";
import { useMailReading } from "@/hooks/use-mail-reading";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
@@ -3149,6 +3151,12 @@ export function EmailViewer({
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml]);
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
// Text size, minimum size and the other reading settings for the body. A
// ref for the iframe setup, which runs from observers wired once per
// document; the srcDoc changes with frameKey, so a new setting rebuilds it.
const reading = useMailReading();
const readingRef = useRef(reading);
readingRef.current = reading;
const [emailViewDarkOverride, setEmailViewDarkOverride] = useState<boolean | null>(null);
const isDark = emailViewDarkOverride !== null
? emailViewDarkOverride
@@ -3204,7 +3212,7 @@ export function EmailViewer({
const iframeCsp = emailIframeCsp(effectiveEmailContent.externalBlocked);
return `<!DOCTYPE html>
<html${bodyLanguage ? ` lang="${bodyLanguage}"` : ''} class="${isDark ? DARK_PENDING_CLASS : ''}" style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<html${bodyLanguage ? ` lang="${bodyLanguage}"` : ''} data-reading="${reading.frameKey}" class="${isDark ? DARK_PENDING_CLASS : ''}" style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
<meta name="referrer" content="no-referrer">
<meta http-equiv="x-dns-prefetch-control" content="off">
@@ -3231,7 +3239,7 @@ export function EmailViewer({
verification email rendered as a logo-only 150px strip). Neutralise the
full-height trick on any element so the body grows to its content. */
[style*="height:100%"], [style*="height: 100%"] { height: auto !important; }
body { margin: 0; padding: ${bodyPadding}; overflow-x: auto; overflow-y: hidden; height: auto !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
body { margin: 0; padding: ${bodyPadding}; overflow-x: auto; overflow-y: hidden; height: auto !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 0.875rem; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
/* Only force the cap on images that set no width of their own: an
!important 100% also overrides a sender's inline max-width, and an
@@ -3256,7 +3264,7 @@ export function EmailViewer({
${wordHtmlCSS}
${darkModeCSS}
</style></head><body dir="auto">${resolveColorSchemeMedia(effectiveEmailContent.html, isDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage]);
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage, reading.frameKey]);
// Unblocking external content is handled by rebuilding the iframe srcDoc:
// toggling allowExternalContent (both "Load images" and "Trust sender" set
@@ -3389,6 +3397,10 @@ export function EmailViewer({
hide: t('hide_quoted_text'),
});
// Text size and minimum size (Settings > Accessibility), before the
// height wiring so the first measurement is already at the new size.
applyMailTextSize(doc, { factor: readingRef.current.textFactor, minPx: readingRef.current.minFontSize });
// Before the height wiring and after the quote toggle exists, so the
// toggle is converted too. Reads the mail's computed colours and
// un-hides the body when done.
@@ -6189,7 +6201,7 @@ export function EmailViewer({
dangerouslySetInnerHTML={plainBodyHtml}
style={{
...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }),
fontSize: '14px',
fontSize: reading.plainFontSize(14),
lineHeight: '1.6',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
+10 -5
View File
@@ -16,6 +16,8 @@ import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidRefere
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { mailLanguage } from "@/lib/mail-language";
import { applyMailTextSize } from "@/lib/mail-reading";
import { useMailReading } from "@/hooks/use-mail-reading";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
@@ -566,6 +568,7 @@ export function ConversationEmailBody({
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
const isDark = resolvedTheme === 'dark' && !emailAlwaysLightMode;
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
const reading = useMailReading();
const showVerificationCodes = useSettingsStore((state) => state.showVerificationCodes);
const verificationCode = useMemo(
() => (showVerificationCodes ? findVerificationCode(email.subject, verificationCodeBodyText(email)) : null),
@@ -771,7 +774,7 @@ export function ConversationEmailBody({
// for whatever the DOM walk above cannot see.
const csp = emailIframeCsp(!allowExternal);
// Colours are rewritten after load (applyEmailDarkMode in handleIframeLoad).
return `<!DOCTYPE html><html${bodyLanguage ? ` lang="${bodyLanguage}"` : ''} class="${isDark ? DARK_PENDING_CLASS : ''}"><head>
return `<!DOCTYPE html><html${bodyLanguage ? ` lang="${bodyLanguage}"` : ''} data-reading="${reading.frameKey}" class="${isDark ? DARK_PENDING_CLASS : ''}"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
@@ -782,7 +785,7 @@ export function ConversationEmailBody({
on a phone fitEmailBodyWidth() shrinks it to the screen first, so the pan is
only left for content too wide to stay legible when scaled. */
html { overflow: hidden; }
body { overflow-x: auto; overflow-y: hidden; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
body { overflow-x: auto; overflow-y: hidden; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 0.875rem; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
/* Only force the cap on images that set no width of their own: an
!important 100% also overrides a sender's inline max-width, and an
image sized by max-width + max-height + width:100% then grows to
@@ -803,7 +806,7 @@ export function ConversationEmailBody({
pre { white-space: pre-wrap; word-wrap: break-word; }
${isDark ? emailDarkModeCss() : ''}
</style></head><body dir="auto">${emailContent.html}</body></html>`;
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage]);
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage, reading.frameKey]);
useLayoutEffect(() => { setHtmlBodyEl(null); }, [emailIframeSrcDoc]);
// One object per body: React writes innerHTML again whenever this object
// changes, dropping whatever was mounted in the body.
@@ -824,6 +827,8 @@ export function ConversationEmailBody({
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
});
// Text size and minimum size, before the first resize.
applyMailTextSize(doc, { factor: reading.textFactor, minPx: reading.minFontSize });
if (isDark) applyEmailDarkMode(doc, { remoteContentAllowed: allowExternal });
const resize = () => {
// Fit desktop-width mail to the screen before measuring: the transform
@@ -868,7 +873,7 @@ export function ConversationEmailBody({
} catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore.
}
}, [t, isDark, allowExternal, email.id, selectionMenu.openRef]);
}, [t, isDark, allowExternal, email.id, selectionMenu.openRef, reading.textFactor, reading.minFontSize]);
return (
<div className={className}>
@@ -941,7 +946,7 @@ export function ConversationEmailBody({
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: '13px' }}
style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: reading.plainFontSize(13) }}
data-plugin-source="email-body"
data-email-id={email.id}
onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email.id)}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useSettingsStore } from "@/stores/settings-store";
import { mailTextFactor, plainBodyFontSize } from "@/lib/mail-reading";
/**
* The reading settings as they apply to the message open right now: "follow
* the system" values resolved against the OS, and the values the body frame
* and the plain-text body need. Shared by the reader and the conversation
* view so a message reads the same in both.
*/
export interface MailReading {
/** Multiplier on the mail's text sizes. */
textFactor: number;
/** Smallest text size in the body, CSS px; 0 = none. */
minFontSize: number;
/** Font size for a plain-text body of `basePx` at 100 %. */
plainFontSize: (basePx: number) => string;
/** Changes whenever the body frame has to be built again. */
frameKey: string;
}
export function useMailReading(): MailReading {
const s = useSettingsStore(useShallow((state) => ({
fontSize: state.fontSize,
mailTextScale: state.mailTextScale,
mailMinFontSize: state.mailMinFontSize,
})));
return useMemo(() => {
const textFactor = mailTextFactor(s.mailTextScale, s.fontSize);
const minFontSize = s.mailMinFontSize || 0;
return {
textFactor,
minFontSize,
plainFontSize: (basePx: number) => plainBodyFontSize(basePx, s.mailTextScale, s.fontSize, minFontSize),
frameKey: `${textFactor}/${minFontSize}`,
};
}, [s.mailTextScale, s.fontSize, s.mailMinFontSize]);
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { applyMailTextSize, mailTextFactor, plainBodyFontSize, scaledTextSize } from '@/lib/mail-reading';
describe('mailTextFactor', () => {
it('follows the app text size when the mail size is 0', () => {
expect(mailTextFactor(0, 'medium')).toBe(1);
expect(mailTextFactor(0, 'xx-large')).toBe(1.5);
});
it('uses the mail size when one is set', () => {
expect(mailTextFactor(1.25, 'huge')).toBe(1.25);
});
});
describe('plainBodyFontSize', () => {
it('stays at the old size by default', () => {
expect(plainBodyFontSize(14, 0, 'medium', 0)).toBe('0.875rem');
});
it('relies on rem for the app size and multiplies only what the mail size adds', () => {
// rem already carries 1.25, so 1.5 needs another 1.2.
expect(plainBodyFontSize(14, 1.5, 'x-large', 0)).toBe('calc(0.875rem * 1.2)');
expect(plainBodyFontSize(14, 0, 'x-large', 0)).toBe('0.875rem');
});
it('puts a floor under it', () => {
expect(plainBodyFontSize(13, 0, 'medium', 16)).toBe('max(0.8125rem, 16px)');
});
});
describe('scaledTextSize', () => {
it('scales font and px line height together', () => {
expect(scaledTextSize(10, 15, { factor: 1.5, minPx: 0 })).toEqual({ font: 15, lineHeight: 22.5 });
});
it('raises text below the minimum, with its line height', () => {
expect(scaledTextSize(9, 12, { factor: 1, minPx: 18 })).toEqual({ font: 18, lineHeight: 24 });
expect(scaledTextSize(20, null, { factor: 1, minPx: 18 })).toEqual({ font: 20, lineHeight: null });
});
it('applies the minimum after scaling', () => {
expect(scaledTextSize(10, null, { factor: 2, minPx: 16 }).font).toBe(20);
expect(scaledTextSize(6, null, { factor: 2, minPx: 16 }).font).toBe(16);
});
});
function mailDoc(html: string): Document {
// A frame's document, so it has a window for getComputedStyle.
const frame = document.createElement('iframe');
document.body.appendChild(frame);
const doc = frame.contentDocument!;
doc.body.innerHTML = html;
return doc;
}
describe('applyMailTextSize', () => {
it('leaves a mail alone at 100 % with no minimum', () => {
const doc = mailDoc('<p style="font-size: 10px">tiny</p>');
expect(applyMailTextSize(doc, { factor: 1, minPx: 0 })).toBe(false);
expect(doc.querySelector('p')!.style.fontSize).toBe('10px');
});
it('enlarges small text to the minimum and pins the rest', () => {
const doc = mailDoc('<div style="font-size: 20px"><span style="font-size: 9px">fine print</span></div>');
doc.body.style.fontSize = '14px';
expect(applyMailTextSize(doc, { factor: 1, minPx: 12 })).toBe(true);
expect(doc.querySelector('span')!.style.getPropertyValue('font-size')).toBe('12px');
expect(doc.querySelector('span')!.style.getPropertyPriority('font-size')).toBe('important');
expect(doc.querySelector('div')!.style.getPropertyValue('font-size')).toBe('20px');
});
it('scales every text size and runs once per document', () => {
const doc = mailDoc('<p style="font-size: 16px; line-height: 20px">a</p>');
doc.body.style.fontSize = '14px';
applyMailTextSize(doc, { factor: 1.5, minPx: 0 });
const p = doc.querySelector('p')!;
expect(p.style.getPropertyValue('font-size')).toBe('24px');
expect(p.style.getPropertyValue('line-height')).toBe('30px');
expect(doc.body.style.getPropertyValue('font-size')).toBe('21px');
expect(applyMailTextSize(doc, { factor: 1.5, minPx: 0 })).toBe(false);
expect(p.style.getPropertyValue('font-size')).toBe('24px');
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* How the reading settings on Settings > Accessibility reach a message body.
*
* HTML mail renders in a sandboxed iframe that the app's stylesheet doesn't
* reach, so the text size, spacing, colours and motion settings have to be
* carried into that document: as CSS appended after the mail's own (so it
* wins the cascade) and, for text size, as a pass over the computed styles
* after the document parsed. Plain-text bodies sit in the app document and
* only need their own font size.
*/
import { FONT_SIZE_SCALE, type FontSize } from '@/lib/accessibility-settings';
/** Size of body text in a message, in CSS px at 100 %: what mail used to be fixed at. */
export const MAIL_BASE_FONT_PX = 14;
/**
* The factor message text is drawn at: the mail text size, or the app's text
* size when that is set to follow it (0).
*/
export function mailTextFactor(mailTextScale: number, fontSize: FontSize): number {
if (mailTextScale > 0) return mailTextScale;
return FONT_SIZE_SCALE[fontSize] ?? 1;
}
/**
* CSS font size for a plain-text body in the app document. Its rem already
* carries the app's text size, so only the part the mail size adds on top of
* that is multiplied in. The minimum, when set, is a floor in px.
*/
export function plainBodyFontSize(basePx: number, mailTextScale: number, fontSize: FontSize, minPx: number): string {
const appFactor = FONT_SIZE_SCALE[fontSize] ?? 1;
const extra = Math.round((mailTextFactor(mailTextScale, fontSize) / appFactor) * 10000) / 10000;
const rem = Math.round((basePx / 16) * 10000) / 10000;
const size = extra === 1 ? `${rem}rem` : `calc(${rem}rem * ${extra})`;
return minPx > 0 ? `max(${size}, ${minPx}px)` : size;
}
/**
* One element's text size after scaling by `factor` and raising it to the
* minimum. A line height in px grows by the same ratio, so the enlarged text
* doesn't overlap the next line; "normal" (null) needs nothing.
*/
export function scaledTextSize(
fontPx: number,
lineHeightPx: number | null,
{ factor, minPx }: { factor: number; minPx: number },
): { font: number; lineHeight: number | null } {
if (!(fontPx > 0)) return { font: fontPx, lineHeight: lineHeightPx };
const font = Math.max(fontPx * factor, minPx || 0);
const ratio = font / fontPx;
return { font, lineHeight: lineHeightPx === null ? null : lineHeightPx * ratio };
}
/** Elements that draw no text of their own. */
const NO_TEXT = new Set(['IMG', 'BR', 'HR', 'WBR', 'PICTURE', 'SOURCE', 'VIDEO', 'AUDIO', 'CANVAS', 'IFRAME', 'OBJECT', 'EMBED', 'STYLE', 'SCRIPT', 'TEMPLATE']);
export interface MailTextSizeOptions {
/** Multiplier on every text size in the mail. */
factor: number;
/** Smallest text size, in CSS px; 0 = no minimum. */
minPx: number;
/** Turn justified text into start-aligned text. */
unjustify?: boolean;
/** Leave sizes alone (the override stylesheet sets them); only unjustify. */
sizesFromCss?: boolean;
}
const DONE_ATTR = 'data-bulwark-text-size';
/**
* Scales the text of a rendered mail document and raises text below the
* minimum, by reading every element's computed font size and writing the new
* one back inline. Reads all elements first and writes after, so the pass
* costs one style recalculation. Once one element changes, every element is
* pinned to its own px size: a child sized in em would otherwise grow a second
* time with its parent. Runs once per document; returns whether it changed
* anything.
*
* Text size works on the text rather than zooming the whole mail, so a
* fixed-width newsletter keeps its width and its text wraps inside it instead
* of pushing the layout off the side of the pane.
*/
export function applyMailTextSize(doc: Document, options: MailTextSizeOptions): boolean {
const body = doc.body;
const view = doc.defaultView;
if (!body || !view || body.hasAttribute(DONE_ATTR)) return false;
const factor = options.sizesFromCss ? 1 : options.factor;
const minPx = options.sizesFromCss ? 0 : options.minPx;
if (factor === 1 && !(minPx > 0) && !options.unjustify) return false;
body.setAttribute(DONE_ATTR, '');
const elements: HTMLElement[] = [body];
body.querySelectorAll<HTMLElement>('*').forEach((el) => {
if (NO_TEXT.has(el.tagName) || el.closest('svg')) return;
elements.push(el);
});
const writes: Array<() => void> = [];
const sizes: Array<{ el: HTMLElement; font: number; lineHeight: number | null; changed: boolean }> = [];
let anySizeChanged = false;
for (const el of elements) {
const style = view.getComputedStyle(el);
if (options.unjustify && style.textAlign === 'justify') {
writes.push(() => el.style.setProperty('text-align', 'start', 'important'));
}
if (factor === 1 && !(minPx > 0)) continue;
const fontPx = parseFloat(style.fontSize);
if (!(fontPx > 0)) continue;
const lineHeightPx = style.lineHeight.endsWith('px') ? parseFloat(style.lineHeight) : null;
const next = scaledTextSize(fontPx, lineHeightPx, { factor, minPx });
const changed = Math.abs(next.font - fontPx) > 0.01;
if (changed) anySizeChanged = true;
sizes.push({ el, font: next.font, lineHeight: next.lineHeight, changed });
}
if (anySizeChanged) {
for (const { el, font, lineHeight } of sizes) {
writes.push(() => {
el.style.setProperty('font-size', `${Math.round(font * 100) / 100}px`, 'important');
if (lineHeight !== null) el.style.setProperty('line-height', `${Math.round(lineHeight * 100) / 100}px`, 'important');
});
}
}
for (const write of writes) write();
return writes.length > 0;
}