diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 30e931b4f..6146b8f05 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -8,6 +8,7 @@ import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isOpenableLinkHref, pl import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { collectReferencedCids, isEmbeddedInBody } 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"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; import { withBasePath } from "@/lib/browser-navigation"; import { buildContactsPath, buildMailPath } from "@/lib/deep-links"; @@ -2228,7 +2229,7 @@ export function EmailViewer({ // Detect if the email HTML has native dark mode support const emailHasNativeDarkMode = useMemo(() => { if (!effectiveEmailContent.isHtml) return false; - return /prefers-color-scheme\s*:\s*dark/i.test(effectiveEmailContent.html); + return htmlHasNativeDarkMode(effectiveEmailContent.html); }, [effectiveEmailContent.html, effectiveEmailContent.isHtml]); const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode); @@ -2240,50 +2241,12 @@ export function EmailViewer({ const emailIframeSrcDoc = useMemo(() => { if (!effectiveEmailContent.isHtml) return ''; - // If email has native dark mode, let it handle its own theming - // Otherwise, use CSS filter inversion for dark mode (preserves layout) - // Re-invert leaf media elements so they appear normal. - // - // Background-COLOR containers (bgcolor, background: shorthand) are - // re-inverted so their fill flips to a dark tone, but skipped when they - // wrap media: the inner image already gets its own re-invert, and a filter - // on the container would double-invert it. Nested color containers must NOT - // stack another invert either - each filter toggles the inversion, so an - // odd number of stacked filters (body + outer + inner) lands back on - // light-on-light; the descendant rule disables filter on a color container - // nested inside another. - // - // Background-IMAGE containers are different: a real photo and whatever is - // composited over it (headline text, logos) was designed by the sender to - // read together, so the whole subtree should return to its original - // light-mode appearance - even when it wraps media. We therefore re-invert - // these unconditionally (no :has guard) and cancel the per-image re-invert - // for media inside them, otherwise those images would flip back to inverted. - const darkModeCSS = isDark && !emailHasNativeDarkMode ? ` - html { background: #121212; } - body { filter: invert(1) hue-rotate(180deg); background: #ededed; } - img, video, svg, canvas, object, embed, input[type="image"] { - filter: invert(1) hue-rotate(180deg); - } - [style*="background:"]:not(:has(img, video, svg, canvas, object, embed)), - [bgcolor]:not(:has(img, video, svg, canvas, object, embed)) { - filter: invert(1) hue-rotate(180deg); - } - :where([style*="background:"], [bgcolor]) - :where([style*="background:"], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) { - filter: none !important; - } - [style*="background-image"], - [background] { - filter: invert(1) hue-rotate(180deg); - } - [style*="background-image"] :where(img, video, svg, canvas, object, embed, input[type="image"]), - [background] :where(img, video, svg, canvas, object, embed, input[type="image"]) { - filter: none; - } - ` : ''; - - const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light'; + // Dark mode rewrites the mail's colours after load (applyEmailDarkMode in + // handleIframeLoad); this only sets the fallback colours and keeps the + // body hidden until that pass ran. A mail with its own dark styles gets + // them pinned to the app theme rather than the OS. + const darkModeCSS = isDark ? emailDarkModeCss() : ''; + const colorScheme = emailColorScheme(isDark, emailHasNativeDarkMode); // Bare HTML emails (no ${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