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 (
diff --git a/hooks/use-mail-reading.ts b/hooks/use-mail-reading.ts index ff7f51669..f6071cb37 100644 --- a/hooks/use-mail-reading.ts +++ b/hooks/use-mail-reading.ts @@ -3,8 +3,18 @@ import { useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import { useSettingsStore } from "@/stores/settings-store"; +import { useThemeStore } from "@/stores/theme-store"; import { useMediaQuery } from "@/hooks/use-media-query"; -import { mailFrameCss, mailTextFactor, plainBodyFontSize, resolveSystemToggle } from "@/lib/mail-reading"; +import { + appFontFamilyName, + collectFontFaces, + mailFrameCss, + mailOverrideActive, + mailTextFactor, + plainBodyFontSize, + resolveSystemToggle, + type MailOverride, +} from "@/lib/mail-reading"; /** * The reading settings as they apply to the message open right now: "follow @@ -19,38 +29,86 @@ export interface MailReading { minFontSize: number; /** Windows contrast themes and the like: the browser picks the colours. */ forcedColors: boolean; - /** Font size for a plain-text body of `basePx` at 100 %. */ - plainFontSize: (basePx: number) => string; /** Reduce motion, resolved: stop animations inside the mail too. */ reduceMotion: boolean; + /** The sender's fonts, sizes and colours give way to the app's. */ + overrideStyles: boolean; + /** Font size for a plain-text body of `basePx` at 100 %. */ + plainFontSize: (basePx: number) => string; /** CSS appended to the mail document for the settings above; may be empty. */ frameCss: string; + /** Folders of app fonts the mail document may load (its CSP font-src). */ + fontSources: string[]; /** Changes whenever the body frame has to be built again. */ frameKey: string; } +/** The app's colours and font right now, read from the page's own styles. */ +function readAppTheme(): Pick { + if (typeof document === 'undefined') return { fontFamily: '', foreground: '', background: '', link: '' }; + const root = getComputedStyle(document.documentElement); + const body = document.body ? getComputedStyle(document.body) : null; + return { + fontFamily: body?.fontFamily || root.getPropertyValue('--font-sans'), + foreground: root.getPropertyValue('--color-foreground') || body?.color || '', + background: root.getPropertyValue('--color-background') || '', + link: root.getPropertyValue('--color-primary') || '', + }; +} + export function useMailReading(): MailReading { const s = useSettingsStore(useShallow((state) => ({ fontSize: state.fontSize, mailTextScale: state.mailTextScale, mailMinFontSize: state.mailMinFontSize, reduceMotion: state.reduceMotion, + mailOverrideStyles: state.mailOverrideStyles, + contrast: state.contrast, + appFont: state.appFont, + lineSpacing: state.lineSpacing, + letterSpacing: state.letterSpacing, + wordSpacing: state.wordSpacing, + paragraphSpacing: state.paragraphSpacing, + underlineLinks: state.underlineLinks, }))); + // The theme's colours are read from the page, so a theme change re-reads them. + const theme = useThemeStore((state) => `${state.resolvedTheme}/${state.activeThemeId ?? ''}`); const forcedColors = useMediaQuery("(forced-colors: active)"); const systemReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + const systemMoreContrast = useMediaQuery("(prefers-contrast: more)"); return useMemo(() => { const textFactor = mailTextFactor(s.mailTextScale, s.fontSize); const minFontSize = s.mailMinFontSize || 0; const reduceMotion = resolveSystemToggle(s.reduceMotion, systemReducedMotion); + const overrideStyles = mailOverrideActive(s.mailOverrideStyles, s.contrast, systemMoreContrast); + let override: MailOverride | null = null; + let fontFaces = { css: '', sources: [] as string[] }; + if (overrideStyles) { + override = { + ...readAppTheme(), + lineSpacing: s.lineSpacing, + letterSpacing: s.letterSpacing, + wordSpacing: s.wordSpacing, + paragraphSpacing: s.paragraphSpacing, + textFactor, + minPx: minFontSize, + }; + const family = appFontFamilyName(s.appFont); + if (family && typeof document !== 'undefined') fontFaces = collectFontFaces(document, family); + } return { textFactor, minFontSize, forcedColors, reduceMotion, - frameCss: mailFrameCss({ reduceMotion }), + overrideStyles, plainFontSize: (basePx: number) => plainBodyFontSize(basePx, s.mailTextScale, s.fontSize, minFontSize), - frameKey: `${textFactor}/${minFontSize}`, + frameCss: mailFrameCss({ reduceMotion, underlineLinks: s.underlineLinks, override, fontFaceCss: fontFaces.css }), + fontSources: fontFaces.sources, + frameKey: `${textFactor}/${minFontSize}${overrideStyles ? '/o' : ''}`, }; - }, [s.mailTextScale, s.fontSize, s.mailMinFontSize, s.reduceMotion, forcedColors, systemReducedMotion]); + // `theme` only marks that the page's colours changed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [s, forcedColors, systemReducedMotion, systemMoreContrast, theme]); } diff --git a/lib/__tests__/email-iframe-csp-fonts.test.ts b/lib/__tests__/email-iframe-csp-fonts.test.ts new file mode 100644 index 000000000..c01140ad4 --- /dev/null +++ b/lib/__tests__/email-iframe-csp-fonts.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { emailIframeCsp } from '@/lib/email-sanitization'; + +describe('emailIframeCsp app fonts', () => { + it('allows only data: fonts by default while remote content is blocked', () => { + expect(emailIframeCsp(true)).toContain('font-src data:;'); + }); + + it('adds the folder of the bundled app font, not the whole origin', () => { + const csp = emailIframeCsp(true, ['https://mail.example/_next/static/media/']); + expect(csp).toContain('font-src data: https://mail.example/_next/static/media/;'); + expect(csp).not.toContain("'self'"); + }); + + it('drops anything that is not a folder URL', () => { + const csp = emailIframeCsp(false, ['https://mail.example/', "https://x/; script-src 'unsafe-inline'", 'https://mail.example/a.woff2', 'javascript:x/']); + expect(csp).toContain('font-src data: https://mail.example/ http: https:;'); + expect(csp).not.toContain('script-src'); + }); +}); diff --git a/lib/__tests__/mail-reading.test.ts b/lib/__tests__/mail-reading.test.ts index 38dae7258..3e41076fa 100644 --- a/lib/__tests__/mail-reading.test.ts +++ b/lib/__tests__/mail-reading.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { applyMailTextSize, mailFrameCss, mailTextFactor, plainBodyFontSize, resolveSystemToggle, scaledTextSize } from '@/lib/mail-reading'; +import { applyMailTextSize, mailFrameCss, mailOverrideActive, mailOverrideCss, mailTextFactor, plainBodyFontSize, resolveSystemToggle, scaledTextSize } from '@/lib/mail-reading'; describe('mailTextFactor', () => { it('follows the app text size when the mail size is 0', () => { @@ -94,3 +94,62 @@ describe('mailFrameCss', () => { expect(mailFrameCss({ reduceMotion: true })).toMatch(/animation-duration:0\.01ms!important.*transition-duration:0\.01ms!important/); }); }); + +describe('mailOverrideActive', () => { + it('always, never, or only with more contrast', () => { + expect(mailOverrideActive('always', 'normal', false)).toBe(true); + expect(mailOverrideActive('never', 'more', true)).toBe(false); + expect(mailOverrideActive('high-contrast', 'more', false)).toBe(true); + expect(mailOverrideActive('high-contrast', 'system', true)).toBe(true); + expect(mailOverrideActive('high-contrast', 'system', false)).toBe(false); + expect(mailOverrideActive('high-contrast', 'normal', true)).toBe(false); + }); +}); + +describe('mailOverrideCss', () => { + const base = { + fontFamily: '"Lexend", sans-serif', foreground: '#111111', background: '#fafafa', link: '#1d4ed8', + lineSpacing: 'normal', letterSpacing: 'normal', wordSpacing: 'normal', paragraphSpacing: 'normal', + textFactor: 1.25, minPx: 0, + } as const; + + it('puts the app font, size and colours on the mail', () => { + const css = mailOverrideCss(base); + expect(css).toContain('html,body{background:#fafafa!important;color:#111111!important}'); + expect(css).toContain('font-family:"Lexend", sans-serif!important;font-size:calc(0.875rem * 1.25)!important'); + expect(css).toMatch(/body \*:not\([^)]*\)\{font-family:inherit!important;font-size:1em!important[^}]*color:#111111!important;background-color:transparent!important;background-image:none!important/); + expect(css).toContain('a[href],a[href] *{color:#1d4ed8!important}'); + expect(css).not.toContain('letter-spacing'); + }); + + it('carries the spacing settings and the minimum size', () => { + const css = mailOverrideCss({ ...base, lineSpacing: 'loose', letterSpacing: 'wider', wordSpacing: 'wide', paragraphSpacing: 'wide', minPx: 18 }); + expect(css).toContain('line-height:1.9!important'); + expect(css).toContain('letter-spacing:0.12em!important'); + expect(css).toContain('word-spacing:0.16em!important'); + expect(css).toContain('body p{margin-block-end:1.5em!important}'); + expect(css).toContain('font-size:max(calc(0.875rem * 1.25), 18px)!important'); + }); + + it('cannot be broken out of by a hostile value', () => { + const css = mailOverrideCss({ ...base, fontFamily: 'x}{' }); + expect(css).not.toContain(''); + expect(css).not.toMatch(/font-family:x\}/); + }); +}); + +describe('mailFrameCss underline', () => { + it('underlines links when asked', () => { + expect(mailFrameCss({ reduceMotion: false, underlineLinks: true })).toBe('a[href]{text-decoration-line:underline!important}'); + }); +}); + +describe('applyMailTextSize with the override stylesheet', () => { + it('leaves sizes to the stylesheet and only takes out justified text', () => { + const doc = mailDoc('

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 = { normal: 1.6, relaxed: 1.6, loose: 1.9 }; +const LETTER_SPACING: Record = { normal: null, wide: '0.05em', wider: '0.12em' }; + +/** Strips what could end the declaration, the rule or the