mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: show HTML mail in the app's font, colours and spacing when styles are overridden
This commit is contained in:
@@ -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 = '<p style="font-family: Comic Sans MS; color: #ff0; background: #00f">Loud <b>mail</b></p>';
|
||||
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}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 `<!DOCTYPE html>
|
||||
<html${bodyLanguage ? ` lang="${bodyLanguage}"` : ''} data-reading="${reading.frameKey}" class="${recolorDark ? DARK_PENDING_CLASS : ''}" style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -3268,7 +3270,7 @@ export function EmailViewer({
|
||||
${wordHtmlCSS}
|
||||
${darkModeCSS}
|
||||
</style></head><body dir="auto">${resolveColorSchemeMedia(effectiveEmailContent.html, recolorDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style>${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
|
||||
}, [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
|
||||
|
||||
@@ -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 `<!DOCTYPE html><html${bodyLanguage ? ` lang="${bodyLanguage}"` : ''} data-reading="${reading.frameKey}" class="${isDark ? DARK_PENDING_CLASS : ''}"><head>
|
||||
<meta charset="utf-8">
|
||||
@@ -808,7 +809,7 @@ export function ConversationEmailBody({
|
||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
${isDark ? emailDarkModeCss() : ''}
|
||||
</style></head><body dir="auto">${emailContent.html}${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
|
||||
}, [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 (
|
||||
<div className={className}>
|
||||
|
||||
@@ -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<MailOverride, 'fontFamily' | 'foreground' | 'background' | 'link'> {
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}</style><img src=x>{' });
|
||||
expect(css).not.toContain('</style>');
|
||||
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('<p style="font-size: 9px; text-align: justify">x</p>');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}. */
|
||||
|
||||
+131
-1
@@ -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<LineSpacing, number> = { normal: 1.6, relaxed: 1.6, loose: 1.9 };
|
||||
const LETTER_SPACING: Record<LetterSpacing, string | null> = { normal: null, wide: '0.05em', wider: '0.12em' };
|
||||
|
||||
/** Strips what could end the declaration, the rule or the <style> element. */
|
||||
function cssValue(value: string): string {
|
||||
return value.replace(/[<>{};\\]/g, '').trim();
|
||||
}
|
||||
|
||||
/** The override stylesheet: the app's font, text size, colours and spacing. */
|
||||
export function mailOverrideCss(o: MailOverride): string {
|
||||
const font = cssValue(o.fontFamily) || 'sans-serif';
|
||||
const fg = cssValue(o.foreground) || '#000';
|
||||
const bg = cssValue(o.background) || '#fff';
|
||||
const link = cssValue(o.link) || fg;
|
||||
const size = `calc(${MAIL_BASE_FONT_PX / 16}rem * ${o.textFactor})`;
|
||||
const bodySize = o.minPx > 0 ? `max(${size}, ${o.minPx}px)` : size;
|
||||
const letter = LETTER_SPACING[o.letterSpacing];
|
||||
// Everything that holds text; images and media keep their own look.
|
||||
const text = 'body *:not(svg,svg *,img,picture,video,canvas)';
|
||||
const parts = [
|
||||
`html,body{background:${bg}!important;color:${fg}!important}`,
|
||||
`body{font-family:${font}!important;font-size:${bodySize}!important;line-height:${LINE_HEIGHT[o.lineSpacing]}!important}`,
|
||||
// Sizes follow the body, headings keep a step above it.
|
||||
`${text}{font-family:inherit!important;font-size:1em!important;line-height:inherit!important;color:${fg}!important;background-color:transparent!important;background-image:none!important;text-shadow:none!important}`,
|
||||
'h1{font-size:1.6em!important}h2{font-size:1.4em!important}h3{font-size:1.2em!important}h4,h5,h6{font-size:1.05em!important}',
|
||||
o.minPx > 0 ? `small,sub,sup{font-size:max(0.85em,${o.minPx}px)!important}` : 'small,sub,sup{font-size:0.85em!important}',
|
||||
`a[href],a[href] *{color:${link}!important}`,
|
||||
// Never justified (the text pass catches justify from <style> rules).
|
||||
'[align="justify"],[style*="justify"]{text-align:start!important}',
|
||||
];
|
||||
if (letter) parts.push(`${text}{letter-spacing:${letter}!important}`);
|
||||
if (o.wordSpacing === 'wide') parts.push(`${text}{word-spacing:0.16em!important}`);
|
||||
if (o.paragraphSpacing === 'wide') parts.push('body p{margin-block-end:1.5em!important}');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS for the reading settings, appended to the mail document after the
|
||||
* mail's own content so it outranks the sender's <style> at equal weight.
|
||||
@@ -144,6 +221,59 @@ export const MAIL_REDUCED_MOTION_CSS = '*,*::before,*::after{animation-duration:
|
||||
*/
|
||||
export function mailFrameCss(options: MailFrameCssOptions): string {
|
||||
const parts: string[] = [];
|
||||
if (options.fontFaceCss) parts.push(options.fontFaceCss);
|
||||
if (options.reduceMotion) parts.push(MAIL_REDUCED_MOTION_CSS);
|
||||
if (options.underlineLinks) parts.push('a[href]{text-decoration-line:underline!important}');
|
||||
if (options.override) parts.push(mailOverrideCss(options.override));
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/** The @font-face family an app font is bundled under (app/accessibility.css), or null for the default. */
|
||||
export function appFontFamilyName(font: AppFont): string | null {
|
||||
switch (font) {
|
||||
case 'atkinson': return 'Atkinson Hyperlegible Next';
|
||||
case 'lexend': return 'Lexend';
|
||||
case 'opendyslexic': return 'OpenDyslexic';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's @font-face rules for `family` with their URLs made absolute, and
|
||||
* the folders those URLs live in (for the mail document's font-src), so the
|
||||
* mail can use the same bundled font. Empty when the rules can't be read.
|
||||
*/
|
||||
export function collectFontFaces(doc: Document, family: string): { css: string; sources: string[] } {
|
||||
const css: string[] = [];
|
||||
const sources = new Set<string>();
|
||||
const wanted = family.toLowerCase();
|
||||
const visit = (list: CSSRuleList, base: string) => {
|
||||
for (const rule of Array.from(list)) {
|
||||
const face = rule as CSSFontFaceRule;
|
||||
if (face.style && rule.cssText.trimStart().toLowerCase().startsWith('@font-face')) {
|
||||
const name = face.style.getPropertyValue('font-family').replace(/["']/g, '').trim().toLowerCase();
|
||||
if (name !== wanted) continue;
|
||||
css.push(rule.cssText.replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/g, (match, _q, url: string) => {
|
||||
try {
|
||||
const absolute = new URL(url, base);
|
||||
if (absolute.protocol !== 'http:' && absolute.protocol !== 'https:') return match;
|
||||
sources.add(new URL('.', absolute).href);
|
||||
return `url("${absolute.href}")`;
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
}));
|
||||
} else if ((rule as CSSGroupingRule).cssRules) {
|
||||
visit((rule as CSSGroupingRule).cssRules, base);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const sheet of Array.from(doc.styleSheets)) {
|
||||
try {
|
||||
visit(sheet.cssRules, sheet.href || doc.baseURI);
|
||||
} catch {
|
||||
// A cross-origin sheet can't be read.
|
||||
}
|
||||
}
|
||||
return { css: css.join('\n'), sources: [...sources] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user