From 7401ea6658f5f30a2364bb9a892c7bb9024d96db Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:43:04 +0200 Subject: [PATCH] fix: recolour mails for dark mode instead of inverting them Dark mode used to invert the whole message body with a CSS filter and re-invert images and coloured cells. Brand colours came out shifted (red turned pink), white cells set through bgcolor stayed white, and the result changed after loading remote content because the blocker strips the background attributes the selectors relied on. The rendered body is now read back through computed styles and its colours are rewritten in place: light backgrounds are darkened in their own hue, vivid and dark fills stay as sent, text and borders only change when they lack contrast, and anything painted on a background image is left alone. Transparent dark images get their original light background behind them when their pixels show they would vanish. The thread conversation view uses the same pass. Also fixed along the way: - blocking remote content dropped every leading `${effectiveEmailContent.html}`; +${resolveColorSchemeMedia(effectiveEmailContent.html, isDark)}`; }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]); // Unblocking external content is handled by rebuilding the iframe srcDoc: @@ -2459,6 +2422,13 @@ export function EmailViewer({ hide: t('hide_quoted_text'), }); + // 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. + if (isDark) { + applyEmailDarkMode(doc, { remoteContentAllowed: !effectiveEmailContent.externalBlocked }); + } + // Auto-resize iframe to fit content // Measure max(documentElement, body): a height:100% wrapper can leave // documentElement.scrollHeight short while the real content lives in body. @@ -2531,7 +2501,7 @@ export function EmailViewer({ // Plugin intercept: let plugins cancel or rewrite external links inside // the email body before navigation happens. Bound on the iframe doc so - // it survives DOM mutations from dark-mode pass below. + // it survives later DOM mutations. const onLinkClick = async (ev: Event) => { const targetEl = (ev.target as Element | null)?.closest?.('a[href], area[href]') as HTMLAnchorElement | HTMLAreaElement | null; if (!targetEl) return; @@ -2562,106 +2532,11 @@ export function EmailViewer({ }; doc.addEventListener('click', onLinkClick, true); - // Dark mode: re-invert elements with stylesheet-defined background images - // (CSS attribute selectors only catch inline styles, not ${emailContent.html}`; - }, [emailContent.isHtml, emailContent.html, allowExternal]); + }, [emailContent.isHtml, emailContent.html, allowExternal, isDark]); const handleIframeLoad = useCallback(() => { const iframe = iframeRef.current; @@ -506,6 +488,7 @@ function EmailCard({ show: t('email_viewer.show_quoted_text'), hide: t('email_viewer.hide_quoted_text'), }); + if (isDark) applyEmailDarkMode(doc, { remoteContentAllowed: allowExternal }); const resize = () => { // Fit desktop-width mail to the screen before measuring: the transform // leaves the layout height untouched, so scale the box by hand. @@ -523,7 +506,7 @@ function EmailCard({ } catch { // contentDocument may be inaccessible under stricter sandboxes; ignore. } - }, [t]); + }, [t, isDark, allowExternal]); return (
{ @@ -130,281 +126,6 @@ describe('getLuminance', () => { }); }); -describe('isDarkColor', () => { - it('should identify dark colors', () => { - expect(isDarkColor('#000000')).toBe(true); - expect(isDarkColor('#111111')).toBe(true); - expect(isDarkColor('#333333')).toBe(true); - expect(isDarkColor('rgb(51, 51, 51)')).toBe(true); - }); - - it('should identify light colors', () => { - expect(isDarkColor('#ffffff')).toBe(false); - expect(isDarkColor('#eeeeee')).toBe(false); - expect(isDarkColor('rgb(200, 200, 200)')).toBe(false); - }); - - it('should return false for invalid colors', () => { - expect(isDarkColor('invalid')).toBe(false); - expect(isDarkColor('inherit')).toBe(false); - }); -}); - -describe('transformColorForDarkMode', () => { - it('should brighten very dark colors', () => { - const original = '#111111'; - const transformed = transformColorForDarkMode(original); - const originalRgb = parseColor(original)!; - const transformedRgb = parseColor(transformed)!; - - expect(transformedRgb.r).toBeGreaterThan(originalRgb.r); - expect(transformedRgb.g).toBeGreaterThan(originalRgb.g); - expect(transformedRgb.b).toBeGreaterThan(originalRgb.b); - }); - - it('should transform #333333 to a bright color', () => { - const transformed = transformColorForDarkMode('#333333'); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeGreaterThan(0.5); - }); - - it('should produce readable results for Google Calendar grays', () => { - const googleGrays = ['#757575', '#5f6368', '#70757a', '#3c4043']; - for (const color of googleGrays) { - const transformed = transformColorForDarkMode(color); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeGreaterThan(0.55); - } - }); - - it('should preserve hue for colored text', () => { - const transformed = transformColorForDarkMode('#1a73e8'); - const rgb = parseColor(transformed)!; - expect(rgb.b).toBeGreaterThan(rgb.r); - expect(rgb.b).toBeGreaterThan(rgb.g); - }); - - it('should preserve already light colors', () => { - const lightColors = ['#eeeeee', '#ffffff', 'rgb(200, 200, 200)']; - lightColors.forEach((color) => { - const original = parseColor(color)!; - const transformed = parseColor(transformColorForDarkMode(color))!; - const originalLum = getLuminance(original.r, original.g, original.b); - const transformedLum = getLuminance(transformed.r, transformed.g, transformed.b); - - expect(transformedLum).toBeGreaterThanOrEqual(originalLum * 0.9); - }); - }); - - it('should handle rgba colors with alpha', () => { - const original = 'rgba(51, 51, 51, 0.8)'; - const transformed = transformColorForDarkMode(original); - expect(transformed).toContain('rgba'); - expect(transformed).toContain('0.8'); - }); - - it('should preserve nearly transparent colors', () => { - const original = 'rgba(0, 0, 0, 0.05)'; - const transformed = transformColorForDarkMode(original); - expect(transformed).toBe(original); - }); - - it('should handle invalid colors gracefully', () => { - expect(transformColorForDarkMode('invalid')).toBe('invalid'); - expect(transformColorForDarkMode('inherit')).toBe('inherit'); - }); - - it('should brighten medium darkness colors', () => { - const original = '#646463'; - const transformed = transformColorForDarkMode(original); - const originalRgb = parseColor(original)!; - const transformedRgb = parseColor(transformed)!; - - expect(transformedRgb.r).toBeGreaterThan(originalRgb.r); - expect(transformedRgb.g).toBeGreaterThan(originalRgb.g); - expect(transformedRgb.b).toBeGreaterThan(originalRgb.b); - }); - - it('should ensure minimum contrast for all dark text colors', () => { - const darkTextColors = [ - '#000000', '#111111', '#222222', '#333333', '#444444', - '#555555', '#666666', '#777777', '#888888', - ]; - for (const color of darkTextColors) { - const transformed = transformColorForDarkMode(color); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeGreaterThan(0.4); - } - }); -}); - -describe('transformBgColorForDarkMode', () => { - it('should darken white backgrounds', () => { - const transformed = transformBgColorForDarkMode('#ffffff'); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeLessThan(0.1); - }); - - it('should darken light gray backgrounds', () => { - const transformed = transformBgColorForDarkMode('#f8f9fa'); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeLessThan(0.1); - }); - - it('should preserve already dark backgrounds', () => { - const darkBgs = ['#111111', '#1a1a2e', '#0f172a']; - for (const color of darkBgs) { - expect(transformBgColorForDarkMode(color)).toBe(color); - } - }); - - it('should preserve nearly transparent backgrounds', () => { - const original = 'rgba(255, 255, 255, 0.05)'; - expect(transformBgColorForDarkMode(original)).toBe(original); - }); - - it('should handle invalid colors gracefully', () => { - expect(transformBgColorForDarkMode('invalid')).toBe('invalid'); - expect(transformBgColorForDarkMode('inherit')).toBe('inherit'); - }); - - it('should handle rgba backgrounds', () => { - const transformed = transformBgColorForDarkMode('rgba(255, 255, 255, 0.9)'); - expect(transformed).toContain('rgba'); - expect(transformed).toContain('0.9'); - const rgb = parseColor(transformed)!; - expect(rgb.r).toBeLessThan(100); - }); - - it('should moderately darken medium backgrounds', () => { - const transformed = transformBgColorForDarkMode('#e0e0e0'); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeLessThan(0.3); - }); -}); - -describe('transformInlineStyles', () => { - it('should not transform styles in light mode', () => { - const original = 'color: #333333; font-size: 16px'; - expect(transformInlineStyles(original, 'light')).toBe(original); - }); - - it('should transform color property in dark mode', () => { - const original = 'color: #333333'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).not.toBe(original); - expect(transformed).toContain('color:'); - expect(transformed).toContain('rgb('); - }); - - it('should darken light background-color property', () => { - const original = 'background-color: #ffffff'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).not.toBe(original); - expect(transformed).toContain('background-color:'); - const colorMatch = transformed.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - expect(colorMatch).not.toBeNull(); - if (colorMatch) { - const [, r] = colorMatch.map(Number); - expect(r).toBeLessThan(100); - } - }); - - it('should preserve dark background-color unchanged', () => { - const original = 'background-color: #111111'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).toBe(original); - }); - - it('should preserve non-color properties', () => { - const original = 'color: #333333; font-size: 16px; margin: 10px'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).toContain('font-size: 16px'); - expect(transformed).toContain('margin: 10px'); - }); - - it('should handle multiple color properties', () => { - const original = 'color: #111111; background-color: #ffffff; font-weight: bold'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).toContain('color:'); - expect(transformed).toContain('background-color:'); - expect(transformed).toContain('font-weight: bold'); - }); - - it('should preserve !important declarations', () => { - const original = 'color: #333333 !important'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).toContain('!important'); - }); - - it('should handle empty or invalid styles', () => { - expect(transformInlineStyles('', 'dark')).toBe(''); - expect(transformInlineStyles('invalid', 'dark')).toBe('invalid'); - }); - - it('should brighten text colors for dark mode readability', () => { - const original = 'color: #333333; font-family: Georgia; font-size: 16px'; - const transformed = transformInlineStyles(original, 'dark'); - - expect(transformed).toContain('font-family: Georgia'); - expect(transformed).toContain('font-size: 16px'); - - const colorMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - expect(colorMatch).not.toBeNull(); - - if (colorMatch) { - const [, r, g, b] = colorMatch.map(Number); - expect(r).toBeGreaterThan(180); - expect(g).toBeGreaterThan(180); - expect(b).toBeGreaterThan(180); - } - }); - - it('should darken background shorthand with color', () => { - const original = 'background: #ffffff'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).not.toBe(original); - expect(transformed).toContain('background:'); - }); - - it('should not transform background with url', () => { - const original = 'background: url(image.jpg) #333333'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).toBe(original); - }); - - it('should transform border-color', () => { - const original = 'border-color: #111111'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).not.toBe(original); - expect(transformed).toContain('border-color:'); - }); - - it('should produce good contrast for Google Calendar emails', () => { - const original = 'color: #5f6368; background-color: #f8f9fa'; - const transformed = transformInlineStyles(original, 'dark'); - - const textMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - const bgMatch = transformed.match(/background-color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - - expect(textMatch).not.toBeNull(); - expect(bgMatch).not.toBeNull(); - - if (textMatch && bgMatch) { - const textLum = getLuminance(+textMatch[1], +textMatch[2], +textMatch[3]); - const bgLum = getLuminance(+bgMatch[1], +bgMatch[2], +bgMatch[3]); - const contrast = (Math.max(textLum, bgLum) + 0.05) / (Math.min(textLum, bgLum) + 0.05); - expect(contrast).toBeGreaterThan(4.5); - } - }); -}); - describe('readableTextOn', () => { it('keeps white text while white holds 3:1 on the fill', () => { expect(readableTextOn('#3b82f6')).toBe('#ffffff'); diff --git a/lib/__tests__/email-dark-mode.test.ts b/lib/__tests__/email-dark-mode.test.ts new file mode 100644 index 000000000..ef72cef8d --- /dev/null +++ b/lib/__tests__/email-dark-mode.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from 'vitest'; +import { + applyEmailDarkMode, + contrastRatio, + darkenBackground, + DARK_PENDING_CLASS, + emailColorScheme, + ensureContrast, + parseComputedColor, + resolveColorSchemeMedia, + type Rgba, +} from '../email-dark-mode'; +import { sanitizeEmailBodyForIframe } from '../email-sanitization'; + +const c = (value: string): Rgba => parseComputedColor(value)!; +const lightness = ({ r, g, b }: Rgba) => (r + g + b) / 3; + +describe('darkenBackground', () => { + it('turns white into a dark canvas', () => { + expect(lightness(darkenBackground(c('rgb(255, 255, 255)')))).toBeLessThan(40); + }); + + it('keeps a light grey canvas lighter than a white card on it', () => { + const card = darkenBackground(c('rgb(255, 255, 255)')); + const canvas = darkenBackground(c('rgb(244, 244, 244)')); + expect(lightness(canvas)).toBeGreaterThan(lightness(card)); + }); + + it('keeps the hue of a light tint', () => { + const out = darkenBackground(c('rgb(214, 233, 248)')); + expect(lightness(out)).toBeLessThan(80); + expect(out.b).toBeGreaterThan(out.r); + }); + + it('leaves vivid brand colours and dark fills as sent', () => { + const red = c('rgb(227, 6, 19)'); + const green = c('rgb(115, 215, 0)'); + const navy = c('rgb(0, 32, 91)'); + expect(darkenBackground(red)).toBe(red); + expect(darkenBackground(green)).toBe(green); + expect(darkenBackground(navy)).toBe(navy); + }); + + it('keeps alpha', () => { + expect(darkenBackground(c('rgba(255, 255, 255, 0.5)')).a).toBe(0.5); + }); +}); + +describe('ensureContrast', () => { + const dark = c('rgb(20, 20, 20)'); + + it('keeps text that already reads', () => { + const white = c('rgb(255, 255, 255)'); + expect(ensureContrast(white, dark, 4.5)).toBe(white); + }); + + it('lifts dark text above 4.5:1 on a dark background', () => { + const out = ensureContrast(c('rgb(51, 51, 51)'), dark, 4.5); + expect(contrastRatio(out, dark)).toBeGreaterThanOrEqual(4.5); + }); + + it('keeps the order of text greys', () => { + const black = ensureContrast(c('rgb(0, 0, 0)'), dark, 4.5); + const grey = ensureContrast(c('rgb(102, 102, 102)'), dark, 4.5); + expect(lightness(black)).toBeGreaterThan(lightness(grey)); + }); + + it('keeps a red link red', () => { + const out = ensureContrast(c('rgb(200, 0, 0)'), dark, 4.5); + expect(out.r).toBeGreaterThan(out.g + 60); + }); +}); + +describe('resolveColorSchemeMedia', () => { + const html = '

(prefers-color-scheme: dark)

'; + + it('turns the dark query on and the light one off in dark mode', () => { + const out = resolveColorSchemeMedia(html, true); + expect(out).toContain('@media (min-width: 0px) { .a'); + expect(out).toContain('@media (max-width: 0.001px) { .b'); + }); + + it('turns the dark query off in light mode', () => { + const out = resolveColorSchemeMedia(html, false); + expect(out).toContain('@media (max-width: 0.001px) { .a'); + expect(out).toContain('@media (min-width: 0px) { .b'); + }); + + it('leaves text outside
', + true, + ); + expect(result.blockedExternalContent).toBe(true); + expect(result.html).toContain(''); + }); + it('leaves external images alone when blocking is off', () => { const result = sanitizeEmailBodyForIframe('', false); expect(result.blockedExternalContent).toBe(false); diff --git a/lib/color-transform.ts b/lib/color-transform.ts index e699db877..dae1db6cf 100644 --- a/lib/color-transform.ts +++ b/lib/color-transform.ts @@ -124,105 +124,3 @@ export function readableTextOn(colorString: string): string { const contrastWithWhite = 1.05 / (getLuminance(rgb.r, rgb.g, rgb.b) + 0.05); return contrastWithWhite >= 3 ? '#ffffff' : '#111827'; } - -export function isDarkColor(colorString: string): boolean { - const rgb = parseColor(colorString); - if (!rgb) return false; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - return luminance < 0.5; -} - -export function transformColorForDarkMode(colorString: string): string { - const rgb = parseColor(colorString); - if (!rgb) return colorString; - - if (rgb.a !== undefined && rgb.a < 0.1) { - return colorString; - } - - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - - if (luminance >= 0.6) return colorString; - - const blendFactor = 0.85 - (luminance / 0.6) * 0.55; - - const r = Math.min(255, Math.round(rgb.r + (255 - rgb.r) * blendFactor)); - const g = Math.min(255, Math.round(rgb.g + (255 - rgb.g) * blendFactor)); - const b = Math.min(255, Math.round(rgb.b + (255 - rgb.b) * blendFactor)); - - return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`; -} - -export function transformBgColorForDarkMode(colorString: string): string { - const rgb = parseColor(colorString); - if (!rgb) return colorString; - - if (rgb.a !== undefined && rgb.a < 0.1) { - return colorString; - } - - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - - if (luminance < 0.2) return colorString; - - const blendFactor = Math.min(0.9, (luminance - 0.2) * 1.125); - const darkR = 30, darkG = 31, darkB = 38; - - const r = Math.max(0, Math.round(rgb.r + (darkR - rgb.r) * blendFactor)); - const g = Math.max(0, Math.round(rgb.g + (darkG - rgb.g) * blendFactor)); - const b = Math.max(0, Math.round(rgb.b + (darkB - rgb.b) * blendFactor)); - - return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`; -} - -export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'): string { - if (theme !== 'dark' || !cssText) { - return cssText; - } - - const styleProps = cssText.split(';').map((prop) => prop.trim()).filter(Boolean); - - const transformedProps = styleProps.map((prop) => { - const colonIndex = prop.indexOf(':'); - if (colonIndex === -1) return prop; - - const property = prop.slice(0, colonIndex).trim(); - const value = prop.slice(colonIndex + 1).trim(); - - if (property === 'color') { - const hasImportant = value.includes('!important'); - const colorValue = value.replace('!important', '').trim(); - const transformed = transformColorForDarkMode(colorValue); - return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`; - } - - if (property === 'background-color') { - const hasImportant = value.includes('!important'); - const colorValue = value.replace('!important', '').trim(); - const transformed = transformBgColorForDarkMode(colorValue); - return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`; - } - - if (property === 'background' && !value.includes('url(')) { - const colorMatch = value.match(/#[0-9a-f]{3,6}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-z]+/i); - if (colorMatch) { - const hasImportant = value.includes('!important'); - const originalColor = colorMatch[0]; - const transformed = transformBgColorForDarkMode(originalColor); - const newValue = value.replace(originalColor, transformed); - return `${property}: ${newValue.replace('!important', '').trim()}${hasImportant ? ' !important' : ''}`; - } - } - - if (property === 'border-color') { - const hasImportant = value.includes('!important'); - const colorValue = value.replace('!important', '').trim(); - const transformed = transformColorForDarkMode(colorValue); - return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`; - } - - return prop; - }); - - return transformedProps.join('; '); -} diff --git a/lib/email-dark-mode.ts b/lib/email-dark-mode.ts new file mode 100644 index 000000000..355f51987 --- /dev/null +++ b/lib/email-dark-mode.ts @@ -0,0 +1,475 @@ +/** + * Dark mode for HTML email bodies rendered in a viewer iframe. + * + * Instead of inverting the whole body with a CSS filter (which turns brand + * red into pink, flips photos that then need re-inverting, and depends on + * which background attributes survived the external-content blocker), the + * rendered document is read back through getComputedStyle and its colours are + * rewritten in place: + * + * - Light backgrounds are darkened with their hue kept. Vivid colours (brand + * buttons, banners) and backgrounds that are already dark stay as sent. + * - Text and borders only change when they lack contrast against the new + * background; they are then lightened (or darkened) in their own hue. + * - Images are never touched. An element painted with a background image keeps + * its colours, and so does the text drawn straight on it: the sender + * designed the two to be read together. + * + * Because the pass works on computed styles, colours from inline styles, + * legacy attributes (bgcolor, ) and