fix: correct dark-mode background-image inversion and height clipping in email viewer

This commit is contained in:
Linus Rath
2026-06-08 15:53:44 +02:00
parent 964136b540
commit a27a5be3dd
+40 -20
View File
@@ -2859,31 +2859,44 @@ export function EmailViewer({
// If email has native dark mode, let it handle its own theming // If email has native dark mode, let it handle its own theming
// Otherwise, use CSS filter inversion for dark mode (preserves layout) // Otherwise, use CSS filter inversion for dark mode (preserves layout)
// Re-invert leaf media elements so they appear normal. // Re-invert leaf media elements so they appear normal.
// Container selectors (bgcolor, background, etc.) use :not(:has(...)) to avoid //
// double re-inverting images nested inside those containers. // Background-COLOR containers (bgcolor, background: shorthand) are
// Nested bgcolor containers must NOT add another invert layer: each filter // re-inverted so their fill flips to a dark tone, but skipped when they
// toggles the inversion, so an odd number of stacked filters (e.g. body + // wrap media: the inner image already gets its own re-invert, and a filter
// outer bgcolor table + inner bgcolor table) produces an inverted result - // on the container would double-invert it. Nested color containers must NOT
// i.e. light-on-light. The second rule disables filter on bgcolor-like // stack another invert either - each filter toggles the inversion, so an
// elements that are descendants of another bgcolor-like element. // 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 ? ` const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #121212; } html { background: #121212; }
body { filter: invert(1) hue-rotate(180deg); background: #ededed; } body { filter: invert(1) hue-rotate(180deg); background: #ededed; }
img, video, svg, canvas, object, embed, input[type="image"] { img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg); filter: invert(1) hue-rotate(180deg);
} }
[style*="background-image"]:not(:has(img, video, svg, canvas, object, embed)),
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)), [style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
[background]:not(:has(img, video, svg, canvas, object, embed)), [bgcolor]:not(:has(img, video, svg, canvas, object, embed)) {
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)),
td[background]:not(:has(img, video, svg, canvas, object, embed)),
table[background]:not(:has(img, video, svg, canvas, object, embed)) {
filter: invert(1) hue-rotate(180deg); filter: invert(1) hue-rotate(180deg);
} }
:where([style*="background-image"], [style*="background:"], [background], [bgcolor]) :where([style*="background:"], [bgcolor])
:where([style*="background-image"], [style*="background:"], [background], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) { :where([style*="background:"], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
filter: none !important; 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'; const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
@@ -2918,7 +2931,11 @@ export function EmailViewer({
<style> <style>
/* Force content height: some emails set html/body { height: 100% }, which - /* Force content height: some emails set html/body { height: 100% }, which -
combined with overflow:hidden and our scrollHeight-based auto-resize - combined with overflow:hidden and our scrollHeight-based auto-resize -
collapses the measured height and clips everything below the fold. */ collapses the measured height and clips everything below the fold. The
height reset is repeated in a trailing <style> after the body content
(see below): the email's own <style> is injected inside our <body> and so
lands later in source order, where - at equal specificity and !important -
it would otherwise win the cascade. */
html, body { overflow: hidden; height: auto !important; } html, body { overflow: hidden; height: auto !important; }
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; } body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } } @media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
@@ -2929,7 +2946,7 @@ export function EmailViewer({
pre { white-space: pre-wrap; word-wrap: break-word; } pre { white-space: pre-wrap; word-wrap: break-word; }
${wordHtmlCSS} ${wordHtmlCSS}
${darkModeCSS} ${darkModeCSS}
</style></head><body>${effectiveEmailContent.html}</body></html>`; </style></head><body>${effectiveEmailContent.html}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]); }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
// Imperatively restore blocked external content inside the iframe document. // Imperatively restore blocked external content inside the iframe document.
@@ -3068,10 +3085,13 @@ export function EmailViewer({
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return; if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
const computed = win.getComputedStyle(htmlEl); const computed = win.getComputedStyle(htmlEl);
if (computed.backgroundImage && computed.backgroundImage !== 'none') { if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Only re-invert if this container doesn't have media children // Re-invert so the background image returns to its original
if (!htmlEl.querySelector('img, video, svg, canvas, object, embed')) { // appearance. Matches the CSS background-image rules: re-invert
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)'; // even when media is nested, then cancel the per-image
} // re-invert on that media so it isn't left double-inverted.
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
htmlEl.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
.forEach(m => { (m as HTMLElement).style.filter = 'none'; });
} }
}); });