From dcc0d600f466106b68d9f4735eb705fcf36fe6df Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:50:31 +0100 Subject: [PATCH] feat: show HTML mail in the app's font, colours and spacing when styles are overridden --- .../email/__tests__/viewer-reading.test.tsx | 33 +++++ components/email/email-viewer.tsx | 19 ++- components/email/thread-conversation-view.tsx | 16 ++- hooks/use-mail-reading.ts | 70 +++++++++- lib/__tests__/email-iframe-csp-fonts.test.ts | 20 +++ lib/__tests__/mail-reading.test.ts | 61 +++++++- lib/email-sanitization.ts | 11 +- lib/mail-reading.ts | 132 +++++++++++++++++- 8 files changed, 341 insertions(+), 21 deletions(-) create mode 100644 lib/__tests__/email-iframe-csp-fonts.test.ts diff --git a/components/email/__tests__/viewer-reading.test.tsx b/components/email/__tests__/viewer-reading.test.tsx index 10a9d84dc..a75e3f7ac 100644 --- a/components/email/__tests__/viewer-reading.test.tsx +++ b/components/email/__tests__/viewer-reading.test.tsx @@ -171,3 +171,36 @@ describe('reduce motion inside the mail', () => { expect(await srcdoc()).not.toContain('animation-duration:0.01ms'); }); }); + +describe('mail in the app font and colours', () => { + const html = '
Loud mail
'; + const srcdoc = async () => (await show(message({ html }))).container.querySelector('iframe')!.getAttribute('srcdoc')!; + + it('leaves the sender styles alone by default', async () => { + expect(await srcdoc()).not.toContain('background-image:none!important'); + }); + + it('replaces them when set to always, and skips the dark recolouring', async () => { + useThemeStore.setState({ resolvedTheme: 'dark' }); + useSettingsStore.setState({ mailOverrideStyles: 'always' } as never); + const doc = await srcdoc(); + expect(doc).toContain('background-image:none!important'); + expect(doc).not.toContain(DARK_PENDING_CLASS); + }); + + it('in high contrast mode only, following the system', async () => { + useSettingsStore.setState({ mailOverrideStyles: 'high-contrast' } as never); + expect(await srcdoc()).not.toContain('background-image:none!important'); + }); + + it('in high contrast mode when the system asks for more contrast', async () => { + useSettingsStore.setState({ mailOverrideStyles: 'high-contrast' } as never); + media.matches.add('(prefers-contrast: more)'); + expect(await srcdoc()).toContain('background-image:none!important'); + }); + + it('underlines links inside the mail when links are underlined', async () => { + useSettingsStore.setState({ underlineLinks: true } as never); + expect(await srcdoc()).toContain('a[href]{text-decoration-line:underline!important}'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e7f6b7498..8c8d5a9ce 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3163,8 +3163,10 @@ export function EmailViewer({ : (emailAlwaysLightMode ? false : resolvedTheme === 'dark'); // Our dark recolouring stays out of a forced-colours mode (Windows contrast // themes): the browser maps the mail to the user's colours itself, and - // inline colours written by our pass would only fight it. - const recolorDark = isDark && !reading.forcedColors; + // inline colours written by our pass would only fight it. With the mail + // shown in the app's own colours there is nothing left to recolour either. + const recolorDark = isDark && !reading.forcedColors && !reading.overrideStyles; + const appFontSources = reading.fontSources.join(' '); // The body's language (Content-Language or the HTML's own lang), so a // screen reader switches voice for it; none when the mail doesn't say. @@ -3213,7 +3215,7 @@ export function EmailViewer({ // Defense-in-depth CSP inside srcDoc (see emailIframeCsp). When the user // loads/trusts the sender the srcDoc is rebuilt (see emailContent) with // the permissive variant so real images, web fonts and media load. - const iframeCsp = emailIframeCsp(effectiveEmailContent.externalBlocked); + const iframeCsp = emailIframeCsp(effectiveEmailContent.externalBlocked, appFontSources ? appFontSources.split(' ') : []); return ` @@ -3268,7 +3270,7 @@ export function EmailViewer({ ${wordHtmlCSS} ${darkModeCSS} ${resolveColorSchemeMedia(effectiveEmailContent.html, recolorDark)}${reading.frameCss ? `` : ''}`; - }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, recolorDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage, reading.frameKey, reading.frameCss]); + }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, recolorDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources]); // Unblocking external content is handled by rebuilding the iframe srcDoc: // toggling allowExternalContent (both "Load images" and "Trust sender" set @@ -3403,7 +3405,14 @@ export function EmailViewer({ // 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 }); + // With the app's styles in charge, sizes come from that stylesheet + // and the pass only takes out justified text. + applyMailTextSize(doc, { + factor: readingRef.current.textFactor, + minPx: readingRef.current.minFontSize, + unjustify: readingRef.current.overrideStyles, + sizesFromCss: readingRef.current.overrideStyles, + }); // Before the height wiring and after the quote toggle exists, so the // toggle is converted too. Reads the mail's computed colours and diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 0a080cc02..ce7246684 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -570,7 +570,8 @@ export function ConversationEmailBody({ const reading = useMailReading(); // No dark recolouring in a forced-colours mode: the browser maps the mail // to the user's colours itself. - const isDark = resolvedTheme === 'dark' && !emailAlwaysLightMode && !reading.forcedColors; + const isDark = resolvedTheme === 'dark' && !emailAlwaysLightMode && !reading.forcedColors && !reading.overrideStyles; + const appFontSources = reading.fontSources.join(' '); const showVerificationCodes = useSettingsStore((state) => state.showVerificationCodes); const verificationCode = useMemo( () => (showVerificationCodes ? findVerificationCode(email.subject, verificationCodeBodyText(email)) : null), @@ -774,7 +775,7 @@ export function ConversationEmailBody({ if (!emailContent.isHtml || !emailContent.html) return ''; // Strict while external content is blocked: the network-level backstop // for whatever the DOM walk above cannot see. - const csp = emailIframeCsp(!allowExternal); + const csp = emailIframeCsp(!allowExternal, appFontSources ? appFontSources.split(' ') : []); // Colours are rewritten after load (applyEmailDarkMode in handleIframeLoad). return ` @@ -808,7 +809,7 @@ export function ConversationEmailBody({ pre { white-space: pre-wrap; word-wrap: break-word; } ${isDark ? emailDarkModeCss() : ''} ${emailContent.html}${reading.frameCss ? `` : ''}`; - }, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage, reading.frameKey, reading.frameCss]); + }, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources]); useLayoutEffect(() => { setHtmlBodyEl(null); }, [emailIframeSrcDoc]); // One object per body: React writes innerHTML again whenever this object // changes, dropping whatever was mounted in the body. @@ -830,7 +831,12 @@ export function ConversationEmailBody({ hide: t('email_viewer.hide_quoted_text'), }); // Text size and minimum size, before the first resize. - applyMailTextSize(doc, { factor: reading.textFactor, minPx: reading.minFontSize }); + applyMailTextSize(doc, { + factor: reading.textFactor, + minPx: reading.minFontSize, + unjustify: reading.overrideStyles, + sizesFromCss: reading.overrideStyles, + }); if (isDark) applyEmailDarkMode(doc, { remoteContentAllowed: allowExternal }); const resize = () => { // Fit desktop-width mail to the screen before measuring: the transform @@ -875,7 +881,7 @@ export function ConversationEmailBody({ } catch { // contentDocument may be inaccessible under stricter sandboxes; ignore. } - }, [t, isDark, allowExternal, email.id, selectionMenu.openRef, reading.textFactor, reading.minFontSize]); + }, [t, isDark, allowExternal, email.id, selectionMenu.openRef, reading.textFactor, reading.minFontSize, reading.overrideStyles]); return (x
'); + applyMailTextSize(doc, { factor: 2, minPx: 16, unjustify: true, sizesFromCss: true }); + const p = doc.querySelector('p')!; + expect(p.style.getPropertyValue('font-size')).toBe('9px'); + expect(p.style.getPropertyValue('text-align')).toBe('start'); + }); +}); diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts index a2a74e500..d860a2a4f 100644 --- a/lib/email-sanitization.ts +++ b/lib/email-sanitization.ts @@ -106,10 +106,15 @@ export function sanitizeEmailHtmlForIframe(html: string): string { * `@font-face`). cid: parts are rewritten to blob: URLs beforehand, so they * survive the strict variant. */ -export function emailIframeCsp(externalBlocked: boolean): string { +export function emailIframeCsp(externalBlocked: boolean, appFontSources: readonly string[] = []): string { + // The folders of the app's own bundled fonts (Settings > Accessibility, mail + // in the app's font). A path, never the whole origin: a mail must not be able + // to make requests to the app's other URLs. + const appFonts = appFontSources.filter((src) => /^https?:\/\/[^\s;,'"]+\/$/.test(src)); + const fontSrc = ['data:', ...appFonts].join(' '); return externalBlocked - ? "default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; media-src data: blob:; base-uri 'none'; form-action 'none'; frame-src 'none'" - : "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'"; + ? `default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src ${fontSrc}; media-src data: blob:; base-uri 'none'; form-action 'none'; frame-src 'none'` + : `default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src ${fontSrc} http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'`; } /** Outcome of {@link sanitizeEmailBodyForIframe}. */ diff --git a/lib/mail-reading.ts b/lib/mail-reading.ts index 455e2ac8d..be7a1fddf 100644 --- a/lib/mail-reading.ts +++ b/lib/mail-reading.ts @@ -9,7 +9,18 @@ * only need their own font size. */ -import { FONT_SIZE_SCALE, type FontSize, type SystemToggle } from '@/lib/accessibility-settings'; +import { + FONT_SIZE_SCALE, + type AppFont, + type ContrastMode, + type FontSize, + type LetterSpacing, + type LineSpacing, + type MailOverrideStyles, + type ParagraphSpacing, + type SystemToggle, + type WordSpacing, +} 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; @@ -130,13 +141,79 @@ export function resolveSystemToggle(value: SystemToggle, systemOn: boolean): boo return value === 'on' || (value === 'system' && systemOn); } +/** Whether the mail's own fonts, sizes and colours give way to the app's. */ +export function mailOverrideActive(mode: MailOverrideStyles, contrast: ContrastMode, systemMoreContrast: boolean): boolean { + if (mode === 'always') return true; + if (mode !== 'high-contrast') return false; + return contrast === 'more' || (contrast === 'system' && systemMoreContrast); +} + +/** The app's look, as the override stylesheet puts it into a mail. */ +export interface MailOverrideTheme { + fontFamily: string; + foreground: string; + background: string; + link: string; +} + +export interface MailSpacing { + lineSpacing: LineSpacing; + letterSpacing: LetterSpacing; + wordSpacing: WordSpacing; + paragraphSpacing: ParagraphSpacing; +} + +export type MailOverride = MailOverrideTheme & MailSpacing & { textFactor: number; minPx: number }; + export interface MailFrameCssOptions { reduceMotion: boolean; + underlineLinks?: boolean; + /** Replace the sender's fonts, sizes and colours; null leaves them. */ + override?: MailOverride | null; + /** @font-face rules the override font needs inside the mail document. */ + fontFaceCss?: string; } /** Stops CSS animations and transitions in a mail (reduce motion). */ export const MAIL_REDUCED_MOTION_CSS = '*,*::before,*::after{animation-duration:0.01ms!important;animation-iteration-count:1!important;animation-delay:0s!important;transition-duration:0.01ms!important;transition-delay:0s!important;scroll-behavior:auto!important}'; +/** Same values the app uses (app/accessibility.css); "normal" is our body default. */ +const LINE_HEIGHT: Record