feat: recolour HTML mail for dark mode instead of inverting it

This commit is contained in:
Linus Rath
2026-10-31 14:34:01 +01:00
parent 5ecf6f3256
commit 23b2bf9732
7 changed files with 946 additions and 184 deletions
+11 -110
View File
@@ -6,6 +6,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Image as ImageIcon, ShieldCheck } from './icons';
import type { Email } from '../api/types';
import { extractCidRefs, isUserLinkNavigation } from '../lib/email-html';
import { buildEmailDarkModeScript } from '../lib/email-dark-mode';
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
import { buildQuoteCollapseScript } from '../lib/quote-collapse';
import { bodyDocument } from '../lib/email-body-document';
@@ -67,109 +68,6 @@ interface EmailBodyViewProps {
onShowFormatted?: () => void;
}
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
// heart -> cyan) and stylesheet-defined background images, which the static CSS
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
// those so they keep their original colors. Only injected when inversion is on.
const DARK_REINVERT_SCRIPT = `
(function () {
if (document.__rnDarkInvertDone) return;
var doc = document;
var win = window;
if (!doc.body) return;
document.__rnDarkInvertDone = true;
// Re-invert elements with stylesheet-defined background images (CSS attribute
// selectors only catch inline styles, not rules that survive in <style>).
doc.body.querySelectorAll('*').forEach(function (el) {
// Skip elements already handled by CSS attribute selectors.
if (el.style.backgroundImage || el.style.background ||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
// Skip leaf media elements (already re-inverted by CSS).
var tag = el.tagName;
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
var computed = win.getComputedStyle(el);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Re-invert so the background image returns to its original
// appearance. Matches the CSS background-image rules: re-invert even
// when media is nested, then cancel the per-image re-invert on that
// media so it isn't left double-inverted.
el.style.filter = 'invert(1) hue-rotate(180deg)';
el.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
.forEach(function (m) { m.style.filter = 'none'; });
}
});
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
// run in a span that re-inverts. Only act when the ancestor invert depth is
// odd - emojis inside a double-inverted bgcolor container already render at
// their original colors.
var emojiRe;
try {
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
} catch (e) {
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
}
var emojiTestRe = /\\p{Extended_Pictographic}/u;
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
function isOddInvertDepth(start) {
var count = 0;
var n = start;
while (n) {
if (n === doc.body) { count++; break; }
var cs = win.getComputedStyle(n);
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
n = n.parentElement;
}
return count % 2 === 1;
}
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function (node) {
var p = node.parentElement;
while (p) {
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return emojiTestRe.test(node.nodeValue || '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
var emojiTextNodes = [];
var cur;
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
emojiTextNodes.forEach(function (textNode) {
var parent = textNode.parentElement;
if (!parent || !isOddInvertDepth(parent)) return;
var text = textNode.nodeValue || '';
emojiRe.lastIndex = 0;
var frag = doc.createDocumentFragment();
var lastIndex = 0;
var m;
while ((m = emojiRe.exec(text)) !== null) {
if (m.index > lastIndex) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
}
var span = doc.createElement('span');
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
span.textContent = m[0];
frag.appendChild(span);
lastIndex = m.index + m[0].length;
}
if (lastIndex === 0) return;
if (lastIndex < text.length) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
}
parent.replaceChild(frag, textNode);
});
})();
`;
// Image fix-ups, mirroring the webmail's post-render passes:
// - collapse empty <td>/<div> wrappers left behind by blocked images so a
// blocked newsletter doesn't show rows of empty cells;
@@ -569,8 +467,8 @@ export default function EmailBodyView({
React.useEffect(() => {
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
// Most marketing email is authored against a white background, so dark-mode
// inversion can wreck logos/banners. With this flag the user opts to render
// Most marketing email is authored against a white background, and some
// people prefer it as sent. With this flag the user opts to render
// emails on a light surface even while the rest of the app is dark; the
// per-message toggle in the More sheet overrides both.
const renderAsDark = themeOverride
@@ -730,18 +628,21 @@ export default function EmailBodyView({
// Still cut off after the API's larger refetch (or the refetch failed).
const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email);
// Inversion is applied for HTML bodies in dark mode unless the email ships
// its own dark-mode CSS - decided once by prepareEmailHtml on the same
// input the CSS was built from, so the DOM pass and the stylesheet agree.
// An HTML body shown dark has its colours rewritten after layout (webmail
// applyEmailDarkMode): decided once by prepareEmailHtml, whose head hides
// the body until this pass ran. It comes after the quote toggle and the
// Play buttons exist, so those are converted too, as in the webmail. Only
// with remote content allowed may a remote image be fetched again to look
// at its pixels.
const injectedJs = React.useMemo(
() =>
(prepared.isHtml ? buildMailTextSizeScript(textFactor) : '')
+ (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
+ (prepared.isHtml && reduceMotion ? buildPauseAnimatedImagesScript(pauseLabels) : '')
+ (prepared.recolorDark ? buildEmailDarkModeScript({ remoteContentAllowed: !shouldBlock }) : '')
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
[prepared.isHtml, prepared.applyInversion, quoteLabels, textFactor, reduceMotion, pauseLabels],
[prepared.isHtml, prepared.recolorDark, shouldBlock, quoteLabels, textFactor, reduceMotion, pauseLabels],
);
// Performance harness: how much of opening a message is the WebView.
+332
View File
@@ -0,0 +1,332 @@
// The dark-mode recolour pass (webmail lib/email-dark-mode.ts). The pass is
// JavaScript source injected into the mail WebView; these tests evaluate that
// same source. The colour cases are the webmail's
// (lib/__tests__/email-dark-mode.test.ts). The app has no DOM library, so the
// DOM pass runs on a small fake document that answers getComputedStyle the
// way a browser does for the parts the pass reads (inline styles win, colour
// inherits, everything else has its initial value).
import { describe, it, expect } from 'vitest';
import {
DARK_PENDING_CLASS,
EMAIL_DARK_MODE_LIB,
buildEmailDarkModeScript,
emailColorScheme,
resolveColorSchemeMedia,
type EmailDarkModeLib,
type Rgba,
} from '../email-dark-mode';
const DM = new Function(`return ${EMAIL_DARK_MODE_LIB}`)()() as EmailDarkModeLib;
const c = (value: string): Rgba => DM.parseComputedColor(value)!;
const lightness = ({ r, g, b }: Rgba) => (r + g + b) / 3;
describe('darkenBackground', () => {
it('turns white into a dark canvas', () => {
expect(lightness(DM.darkenBackground(c('rgb(255, 255, 255)')))).toBeLessThan(40);
});
it('keeps a light grey canvas lighter than a white card on it', () => {
const card = DM.darkenBackground(c('rgb(255, 255, 255)'));
const canvas = DM.darkenBackground(c('rgb(244, 244, 244)'));
expect(lightness(canvas)).toBeGreaterThan(lightness(card));
});
it('keeps the hue of a light tint', () => {
const out = DM.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(DM.darkenBackground(red)).toBe(red);
expect(DM.darkenBackground(green)).toBe(green);
expect(DM.darkenBackground(navy)).toBe(navy);
});
it('keeps alpha', () => {
expect(DM.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(DM.ensureContrast(white, dark, 4.5)).toBe(white);
});
it('lifts dark text above 4.5:1 on a dark background', () => {
const out = DM.ensureContrast(c('rgb(51, 51, 51)'), dark, 4.5);
expect(DM.contrastRatio(out, dark)).toBeGreaterThanOrEqual(4.5);
});
it('keeps the order of text greys', () => {
const black = DM.ensureContrast(c('rgb(0, 0, 0)'), dark, 4.5);
const grey = DM.ensureContrast(c('rgb(102, 102, 102)'), dark, 4.5);
expect(lightness(black)).toBeGreaterThan(lightness(grey));
});
it('keeps a red link red', () => {
const out = DM.ensureContrast(c('rgb(200, 0, 0)'), dark, 4.5);
expect(out.r).toBeGreaterThan(out.g + 60);
});
});
describe('parseComputedColor / formatColor', () => {
it('reads the forms getComputedStyle returns', () => {
expect(c('rgb(1, 2, 3)')).toEqual({ r: 1, g: 2, b: 3, a: 1 });
expect(c('rgba(1, 2, 3, 0.5)')).toEqual({ r: 1, g: 2, b: 3, a: 0.5 });
expect(c('rgb(1 2 3 / 50%)')).toEqual({ r: 1, g: 2, b: 3, a: 0.5 });
expect(DM.parseComputedColor('transparent')).toBeNull();
});
it('writes rgb() when opaque and rgba() otherwise', () => {
expect(DM.formatColor({ r: 1.4, g: 2, b: 3, a: 1 })).toBe('rgb(1, 2, 3)');
expect(DM.formatColor({ r: 1, g: 2, b: 3, a: 0.25 })).toBe('rgba(1, 2, 3, 0.25)');
});
});
describe('resolveColorSchemeMedia', () => {
const html = '<style>@media (prefers-color-scheme: dark) { .a { color: white } } @media (prefers-color-scheme:light) { .b {} }</style><p>(prefers-color-scheme: dark)</p>';
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 <style> alone', () => {
expect(resolveColorSchemeMedia(html, true)).toContain('<p>(prefers-color-scheme: dark)</p>');
});
});
describe('emailColorScheme', () => {
it('is dark only for a mail with its own dark styles shown dark', () => {
expect(emailColorScheme(true, true)).toBe('dark');
expect(emailColorScheme(true, false)).toBe('light');
expect(emailColorScheme(false, true)).toBe('light');
});
});
// ─── A fake rendered document ─────────────────────────────────────────────
type Css = Partial<Record<'color' | 'backgroundColor' | 'backgroundImage' | 'border', string>>;
class FakeStyle {
private props = new Map<string, { value: string; priority: string }>();
setProperty(name: string, value: string, priority = '') { this.props.set(name, { value, priority }); }
getPropertyValue(name: string) { return this.props.get(name)?.value ?? ''; }
getPropertyPriority(name: string) { return this.props.get(name)?.priority ?? ''; }
}
class FakeElement {
parentElement: FakeElement | null = null;
children: FakeElement[] = [];
style = new FakeStyle();
private classes = new Set<string>();
classList = {
remove: (name: string) => { this.classes.delete(name); },
contains: (name: string) => this.classes.has(name),
add: (name: string) => { this.classes.add(name); },
};
constructor(public tagName: string, public css: Css = {}, public id = '') {}
append(...kids: FakeElement[]) {
for (const kid of kids) {
kid.parentElement = this;
this.children.push(kid);
}
return this;
}
descendants(): FakeElement[] {
return this.children.flatMap((kid) => [kid, ...kid.descendants()]);
}
querySelectorAll(selector: string): FakeElement[] {
const all = this.descendants();
return selector === '*' ? all : all.filter((el) => el.tagName.toLowerCase() === selector);
}
closest(selector: string): FakeElement | null {
for (let n: FakeElement | null = this; n; n = n.parentElement) {
if (n.tagName.toLowerCase() === selector) return n;
}
return null;
}
}
/** What the browser would report: the pass's inline writes win, colour inherits. */
function computed(el: FakeElement) {
const own = (prop: string, css: string | undefined) => el.style.getPropertyValue(prop) || css;
const color = (): string => {
for (let n: FakeElement | null = el; n; n = n.parentElement) {
const v = n.style.getPropertyValue('color') || n.css.color;
if (v) return v;
}
return 'rgb(0, 0, 0)';
};
const border = el.css.border;
return {
color: color(),
backgroundColor: own('background-color', el.css.backgroundColor) ?? 'rgba(0, 0, 0, 0)',
backgroundImage: own('background-image', el.css.backgroundImage) ?? 'none',
getPropertyValue(name: string) {
const side = /^border-(top|right|bottom|left)-(style|width|color)$/.exec(name);
if (!side) return '';
if (side[2] === 'color') return el.style.getPropertyValue(name) || border || '';
if (side[2] === 'style') return border ? 'solid' : 'none';
return border ? '1px' : '0px';
},
};
}
function hex(value: string): string {
const h = value.replace('#', '');
const full = h.length === 3 ? h.split('').map((d) => d + d).join('') : h;
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
return `rgb(${r}, ${g}, ${b})`;
}
/**
* A document in the state the head CSS leaves it in: pending class on
* <html>, our dark canvas on <html> and <body>, light fallback text.
*/
function makeDoc(content: FakeElement[], opts: { htmlBg?: string; bodyBg?: string } = {}) {
const html = new FakeElement('HTML', { backgroundColor: opts.htmlBg ?? 'rgb(20, 20, 20)' });
const body = new FakeElement('BODY', { backgroundColor: opts.bodyBg ?? 'rgb(20, 20, 20)', color: 'rgb(230, 230, 230)' });
html.classList.add(DARK_PENDING_CLASS);
html.append(body);
body.append(...content);
const doc = {
documentElement: html,
body,
defaultView: { getComputedStyle: computed },
byId: (id: string) => html.descendants().find((e) => e.id === id)!,
};
return doc;
}
const run = (doc: ReturnType<typeof makeDoc>) => DM.apply(doc as unknown as Document);
const writtenBg = (el: FakeElement) => {
expect(el.style.getPropertyPriority('background-color')).toBe('important');
return c(el.style.getPropertyValue('background-color'));
};
const el = (tag: string, css: Css = {}, id = '', ...kids: FakeElement[]) => new FakeElement(tag, css, id).append(...kids);
describe('the DOM pass', () => {
it('darkens a white cell', () => {
const doc = makeDoc([el('TABLE', {}, '', el('TR', {}, '', el('TD', { backgroundColor: hex('#fff') }, 'a')))]);
run(doc);
expect(lightness(writtenBg(doc.byId('a')))).toBeLessThan(40);
});
it('makes dark text on a darkened cell readable', () => {
const doc = makeDoc([el('DIV', { backgroundColor: hex('#fff') }, 'd', el('P', { color: hex('#222') }, 'p'))]);
run(doc);
const p = doc.byId('p');
expect(p.style.getPropertyPriority('color')).toBe('important');
const bg = writtenBg(doc.byId('d'));
expect(DM.contrastRatio(c(p.style.getPropertyValue('color')), bg)).toBeGreaterThan(4.5);
});
it('keeps a brand button and its label as sent', () => {
const doc = makeDoc([el('A', { backgroundColor: hex('#e30613'), color: hex('#ffffff') }, 'btn')]);
run(doc);
const btn = doc.byId('btn');
expect(btn.style.getPropertyPriority('background-color')).toBe('');
expect(btn.style.getPropertyPriority('color')).toBe('');
});
it('leaves text on a background image as sent', () => {
const doc = makeDoc([
el('DIV', { backgroundImage: 'url("data:image/png;base64,AAAA")', backgroundColor: hex('#ffffff') }, 'photo',
el('P', { color: hex('#111') }, 'p')),
]);
run(doc);
expect(doc.byId('p').style.getPropertyPriority('color')).toBe('');
expect(doc.byId('photo').style.getPropertyPriority('background-color')).toBe('');
});
it('converts a cell whose remote background the blocker stripped', () => {
const doc = makeDoc([
el('TD', { backgroundImage: 'url("")', backgroundColor: hex('#ffffff') }, 'hero', el('P', { color: hex('#111') }, 'p')),
]);
run(doc);
expect(lightness(writtenBg(doc.byId('hero')))).toBeLessThan(40);
expect(doc.byId('p').style.getPropertyPriority('color')).toBe('important');
});
it('keeps a vivid cell as sent', () => {
const doc = makeDoc([el('TD', { backgroundColor: hex('#e30613'), color: hex('#ffffff') }, 'x')]);
run(doc);
expect(doc.byId('x').style.getPropertyValue('background-color')).toBe('');
});
it('darkens a light border with the backgrounds', () => {
const doc = makeDoc([el('DIV', { backgroundColor: hex('#ffffff'), border: hex('#dddddd') }, 'card')]);
run(doc);
const card = doc.byId('card');
expect(card.style.getPropertyPriority('border-top-color')).toBe('important');
expect(lightness(c(card.style.getPropertyValue('border-top-color')))).toBeLessThan(110);
});
it('leaves the dark canvas behind a mail without colours alone', () => {
const doc = makeDoc([el('P', {}, 'p')]);
run(doc);
expect(doc.body.style.getPropertyValue('background-color')).toBe('');
expect(doc.documentElement.style.getPropertyValue('background-color')).toBe('');
// Our light fallback text already reads on it.
expect(doc.byId('p').style.getPropertyValue('color')).toBe('');
});
it('un-hides the body', () => {
const doc = makeDoc([el('P')]);
run(doc);
expect(doc.documentElement.classList.contains(DARK_PENDING_CLASS)).toBe(false);
});
// Newsletters (Lieferando) paint the canvas through `html { background }`.
it('darkens a canvas the mail paints on <html>', () => {
const doc = makeDoc([el('P')], { htmlBg: hex('#ffffff'), bodyBg: hex('#ffffff') });
run(doc);
expect(lightness(writtenBg(doc.documentElement))).toBeLessThan(40);
expect(lightness(writtenBg(doc.body))).toBeLessThan(40);
});
});
describe('buildEmailDarkModeScript', () => {
const inject = (script: string, doc: ReturnType<typeof makeDoc>) => {
const win: Record<string, unknown> = {};
new Function('document', 'window', script)(doc, win);
};
it('runs the pass on the page once, however often the view injects it', () => {
const doc = makeDoc([el('TD', { backgroundColor: hex('#fff') }, 'a')]);
const script = buildEmailDarkModeScript({ remoteContentAllowed: false });
inject(script, doc);
const first = doc.byId('a').style.getPropertyValue('background-color');
expect(lightness(c(first))).toBeLessThan(40);
// A second pass would read the darkened colour back and could shift it.
doc.byId('a').style.setProperty('background-color', 'rgb(255, 255, 255)');
inject(script, doc);
expect(doc.byId('a').style.getPropertyValue('background-color')).toBe('rgb(255, 255, 255)');
});
it('shows the body even when the pass throws', () => {
const doc = makeDoc([el('P')]);
(doc.defaultView as { getComputedStyle: unknown }).getComputedStyle = () => { throw new Error('boom'); };
inject(buildEmailDarkModeScript(), doc);
expect(doc.documentElement.classList.contains(DARK_PENDING_CLASS)).toBe(false);
});
});
+30 -12
View File
@@ -254,21 +254,39 @@ describe('prepareEmailHtml', () => {
expect(prepareEmailHtml('<p>x</p>').html).not.toMatch(/<script/i);
});
it('evaluates native dark mode once and reports the inversion decision', () => {
const native = prepareEmailHtml('<style>@media (prefers-color-scheme: dark){body{color:#fff}}</style><p>x</p>', { isDark: true });
expect(native.hasNativeDark).toBe(true);
expect(native.applyInversion).toBe(false);
expect(native.html).toContain('color-scheme: light dark');
expect(native.html).not.toContain('invert(1)');
// Dark mode rewrites the mail's colours after layout (lib/email-dark-mode)
// instead of inverting the body with a filter, as the webmail does since
// 7401ea665: the head only sets fallback colours and hides the body until
// the pass ran.
it('recolours a mail shown dark instead of inverting it', () => {
const plain = prepareEmailHtml('<p>x</p>', { isDark: true });
expect(plain.applyInversion).toBe(true);
expect(plain.html).toContain('body { filter: invert(1) hue-rotate(180deg)');
// Current webmail revision: background-image containers re-invert without the :has guard.
expect(plain.html).toMatch(/\[style\*="background-image"\],\s*\[background\]\s*\{\s*filter: invert\(1\)/);
expect(plain.recolorDark).toBe(true);
expect(plain.html).not.toContain('invert(1)');
expect(plain.html).toContain('<html class="bulwark-dark-pending"');
expect(plain.html).toContain('body { color: #e6e6e6; background: #141414; }');
expect(plain.html).toContain('animation: bulwark-dark-reveal 0s 1.5s forwards');
const light = prepareEmailHtml('<p>x</p>', { isDark: false });
expect(light.applyInversion).toBe(false);
expect(light.recolorDark).toBe(false);
expect(light.html).not.toContain('bulwark-dark-pending');
expect(light.html).not.toContain('#141414');
});
it('recolours a mail with its own dark styles too and pins them to the app theme', () => {
const mail = '<style>@media (prefers-color-scheme: dark){body{color:#fff}} @media (prefers-color-scheme: light){p{color:#000}}</style><p>x</p>';
const dark = prepareEmailHtml(mail, { isDark: true });
expect(dark.hasNativeDark).toBe(true);
expect(dark.recolorDark).toBe(true);
expect(dark.html).toContain('color-scheme: dark');
expect(dark.html).toContain('@media (min-width: 0px){body{color:#fff}}');
expect(dark.html).toContain('@media (max-width: 0.001px){p{color:#000}}');
// A light app (or "Always view emails in light mode") on a dark phone
// must not get the mail's dark variant.
const light = prepareEmailHtml(mail, { isDark: false });
expect(light.html).toContain('color-scheme: light');
expect(light.html).not.toContain('light dark');
expect(light.html).toContain('@media (max-width: 0.001px){body{color:#fff}}');
});
it('reports blocked external content', () => {
+3 -3
View File
@@ -37,8 +37,8 @@ export interface BodyDocumentInput {
export interface BodyDocument {
html: string;
/** Inversion CSS is in effect; the DOM re-invert pass should run. */
applyInversion: boolean;
/** Shown dark: the recolour pass (lib/email-dark-mode) has to run. */
recolorDark: boolean;
hasNativeDark: boolean;
/** At least one external resource was neutralised. */
blockedExternal: boolean;
@@ -69,7 +69,7 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
fontPx: mailBaseFontPx(input.textFactor ?? 1),
darkColors: input.plainDarkColors,
}),
applyInversion: false,
recolorDark: false,
hasNativeDark: false,
blockedExternal: false,
isHtml: false,
+544
View File
@@ -0,0 +1,544 @@
/**
* Dark mode for HTML mail bodies (webmail lib/email-dark-mode.ts, 7401ea665,
* 6e5e07107). Instead of inverting the whole body with a CSS filter (which
* turns brand red into pink, flips photos that then need re-inverting and
* emoji that need wrapping), 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.
*
* Computed styles only exist inside the WebView, so the pass runs there as an
* injected script. Hermes keeps no function source, so the pass can't be
* stringified from TypeScript: it lives below as JavaScript source
* (EMAIL_DARK_MODE_LIB), and the tests evaluate that same source, so what is
* tested is what the WebView runs.
*/
/** Canvas the converted mail sits on (white maps to this). */
export const EMAIL_DARK_BACKGROUND = '#141414';
const DARK_TEXT = '#e6e6e6';
const DARK_LINK = '#8ab4f8';
/** Class on <html> that keeps the body hidden until the pass has run. */
export const DARK_PENDING_CLASS = 'bulwark-dark-pending';
/**
* `color-scheme` for the document root. A mail with its own dark styles gets
* `dark`, so its `prefers-color-scheme: dark` rules apply whenever the app
* shows it dark - not only when the phone is dark too.
*/
export function emailColorScheme(isDark: boolean, nativeDarkMode: boolean): 'light' | 'dark' {
return isDark && nativeDarkMode ? 'dark' : 'light';
}
const ALWAYS = '(min-width: 0px)';
const NEVER = '(max-width: 0.001px)';
/**
* Pins the mail's `prefers-color-scheme` media queries to the theme the app
* shows it in. Inside the WebView they would follow the phone, not the app
* (or the per-message light/dark switch). Only <style> blocks are rewritten.
*/
export function resolveColorSchemeMedia(html: string, isDark: boolean): string {
if (!/prefers-color-scheme/i.test(html)) return html;
return html.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_m, open: string, css: string, close: string) =>
open + css.replace(/\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/gi, (_q, scheme: string) =>
(scheme.toLowerCase() === 'dark') === isDark ? ALWAYS : NEVER) + close);
}
/**
* Stylesheet for the document head in dark mode. Sets the fallback colours
* for mails that set none, and hides the body until the pass ran so the light
* original never flashes. The reveal animation is a failsafe: the body shows
* after a moment even if the pass never runs.
*/
export function emailDarkModeCss(): string {
return `
html { background: ${EMAIL_DARK_BACKGROUND}; }
body { color: ${DARK_TEXT}; background: ${EMAIL_DARK_BACKGROUND}; }
a { color: ${DARK_LINK}; }
@keyframes bulwark-dark-reveal { to { visibility: visible; } }
html.${DARK_PENDING_CLASS} { background-color: ${EMAIL_DARK_BACKGROUND} !important; }
html.${DARK_PENDING_CLASS} body { visibility: hidden; animation: bulwark-dark-reveal 0s 1.5s forwards; }
`;
}
/** An sRGB colour with alpha, as the pass reads it from computed styles. */
export interface Rgba { r: number; g: number; b: number; a: number }
export interface EmailDarkModeOptions {
/**
* The user allowed remote content for this message. Only then may a remote
* image be fetched a second time (with CORS) to look at its pixels.
*/
remoteContentAllowed?: boolean;
}
/** What EMAIL_DARK_MODE_LIB evaluates to. */
export interface EmailDarkModeLib {
parseComputedColor(value: string): Rgba | null;
formatColor(color: Rgba): string;
contrastRatio(x: Rgba, y: Rgba): number;
darkenBackground(color: Rgba): Rgba;
ensureContrast(color: Rgba, bg: Rgba, minRatio: number): Rgba;
hasImage(backgroundImage: string): boolean;
apply(doc: Document, options?: EmailDarkModeOptions): void;
}
/**
* The pass as JavaScript source: an expression that evaluates to a factory
* returning {@link EmailDarkModeLib}. A line-for-line port of the webmail's
* lib/email-dark-mode.ts (colour maths in OKLab/OKLCH, WCAG contrast, the
* read-then-write DOM pass and the image plates); keep the two in step.
* No template literals or `${` inside: the source is a raw string.
*/
export const EMAIL_DARK_MODE_LIB = String.raw`(function () {
'use strict';
// Colour maths (sRGB <-> OKLab/OKLCH, WCAG contrast)
function parseComputedColor(value) {
var m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,\/]\s*([\d.]+%?))?\s*\)$/i.exec(value || '');
if (!m) return null;
var a = 1;
if (m[4] !== undefined && m[4] !== '') {
a = m[4].charAt(m[4].length - 1) === '%' ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
}
return { r: +m[1], g: +m[2], b: +m[3], a: a };
}
function formatColor(c) {
var R = Math.round(c.r), G = Math.round(c.g), B = Math.round(c.b);
return c.a >= 1
? 'rgb(' + R + ', ' + G + ', ' + B + ')'
: 'rgba(' + R + ', ' + G + ', ' + B + ', ' + (+c.a.toFixed(3)) + ')';
}
function toLinear(v) {
var c = v / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function fromLinear(v) {
var c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
return c * 255;
}
function toLch(c) {
var lr = toLinear(c.r), lg = toLinear(c.g), lb = toLinear(c.b);
var l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
var m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
var s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
var L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
var A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
var B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
return { l: L, c: Math.hypot(A, B), h: Math.atan2(B, A) };
}
function lchToLinear(lch) {
var A = lch.c * Math.cos(lch.h), B = lch.c * Math.sin(lch.h), L = lch.l;
var l = Math.pow(L + 0.3963377774 * A + 0.2158037573 * B, 3);
var m = Math.pow(L - 0.1055613458 * A - 0.0638541728 * B, 3);
var s = Math.pow(L - 0.0894841775 * A - 1.291485548 * B, 3);
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
];
}
function outOfGamut(lin) {
for (var i = 0; i < 3; i++) if (lin[i] < -1e-4 || lin[i] > 1 + 1e-4) return true;
return false;
}
// OKLCH back to sRGB, pulling chroma in until the colour fits the gamut.
function fromLch(lch, a) {
var c = lch.c;
var lin = lchToLinear({ l: lch.l, c: c, h: lch.h });
for (var i = 0; i < 24 && outOfGamut(lin); i++) {
c *= 0.9;
lin = lchToLinear({ l: lch.l, c: c, h: lch.h });
}
var out = lin.map(function (v) {
return Math.min(255, Math.max(0, fromLinear(Math.min(1, Math.max(0, v)))));
});
return { r: out[0], g: out[1], b: out[2], a: a };
}
function luminance(c) {
return 0.2126 * toLinear(c.r) + 0.7152 * toLinear(c.g) + 0.0722 * toLinear(c.b);
}
function contrastRatio(x, y) {
var lx = luminance(x), ly = luminance(y);
return (Math.max(lx, ly) + 0.05) / (Math.min(lx, ly) + 0.05);
}
function sameColor(x, y) {
return Math.round(x.r) === Math.round(y.r) && Math.round(x.g) === Math.round(y.g)
&& Math.round(x.b) === Math.round(y.b) && x.a === y.a;
}
// Paints a translucent colour over an opaque one.
function composite(top, under) {
var a = top.a;
return {
r: top.r * a + under.r * (1 - a),
g: top.g * a + under.g * (1 - a),
b: top.b * a + under.b * (1 - a),
a: 1
};
}
// Lightness white lands on, and the bottom of the "already dark" range.
var BG_WHITE_L = 0.2;
var BG_DARK_L = 0.45;
// Chroma from which a background counts as a deliberate, vivid colour.
var VIVID_CHROMA = 0.1;
// Dark counterpart of a background colour. Light colours are mirrored into
// the dark range (white -> the dark canvas, light grey -> a little lighter,
// so the mail's card/canvas layering survives), mid greys move down to meet
// that range, and vivid or already-dark colours are left alone.
function darkenBackground(color) {
if (color.a === 0) return color;
var lch = toLch(color);
if (lch.l <= BG_DARK_L) return color;
if (lch.c >= VIVID_CHROMA) return color;
var l;
if (lch.l >= 0.7) {
// 1.0 -> 0.2, 0.7 -> 0.38
l = BG_WHITE_L + (1 - lch.l) * 0.6;
} else {
// 0.45 (kept) ... 0.7 (-> 0.38): fold mid greys into the dark range.
l = BG_DARK_L - ((lch.l - BG_DARK_L) / (0.7 - BG_DARK_L)) * (BG_DARK_L - 0.38);
}
return fromLch({ l: l, c: lch.c, h: lch.h }, color.a);
}
// Returns color if it already reads against bg, otherwise a colour of the
// same hue moved away from the background's lightness until it reaches
// minRatio. Dark text on a dark background is mirrored first (black -> near
// white, dark grey -> light grey), which keeps the sender's hierarchy of
// text greys instead of flattening them all to one value.
function ensureContrast(color, bg, minRatio) {
if (color.a === 0) return color;
var opaque = color.a < 1 ? composite(color, bg) : color;
if (contrastRatio(opaque, bg) >= minRatio) return color;
var lch = toLch(opaque);
var bgL = toLch(bg).l;
var lighten = bgL < 0.6;
var l = lighten ? Math.max(lch.l, 0.96 - lch.l * 0.5) : Math.min(lch.l, 0.3);
var out = fromLch({ l: l, c: lch.c, h: lch.h }, 1);
for (var i = 0; i < 40 && contrastRatio(out, bg) < minRatio; i++) {
l = lighten ? Math.min(1, l + 0.02) : Math.max(0, l - 0.02);
out = fromLch({ l: l, c: lch.c, h: lch.h }, 1);
if (l === 0 || l === 1) break;
}
return out;
}
// Rewrites every rgb()/rgba() in a computed gradient.
function mapGradientColors(value, map) {
return value.replace(/rgba?\([^)]*\)/gi, function (m) {
var c = parseComputedColor(m);
return c ? formatColor(map(c)) : m;
});
}
// The DOM pass
var BORDER_SIDES = ['top', 'right', 'bottom', 'left'];
var WHITE = { r: 255, g: 255, b: 255, a: 1 };
var SKIP_TAGS = {
IMG: 1, VIDEO: 1, CANVAS: 1, PICTURE: 1, SOURCE: 1, svg: 1, SVG: 1, STYLE: 1, SCRIPT: 1,
BR: 1, WBR: 1, HEAD: 1, TITLE: 1, META: 1, LINK: 1
};
// True for a background-image that actually paints a picture.
function hasImage(backgroundImage) {
if (!backgroundImage || backgroundImage === 'none') return false;
// The blocker rewrites remote urls to url() (computed as url("")); those
// paint nothing.
return /url\(/i.test(backgroundImage.replace(/url\(\s*(?:""|''|)\s*\)/gi, ''));
}
// Lifts a kept dark fill that stood out from its light surroundings (a
// near-black button on a white card) but now sinks into the darkened
// surface around it.
function keepSeparation(next, original, parent) {
if (next !== original || original.a < 0.9) return next;
if (contrastRatio(original, parent.original) < 3) return next;
var target = 1.8;
if (contrastRatio(next, parent.bg) >= target) return next;
var lch = toLch(next);
var parentL = toLch(parent.bg).l;
var l = Math.max(lch.l, parentL);
var out = next;
for (var i = 0; i < 25 && contrastRatio(out, parent.bg) < target; i++) {
l += 0.02;
out = fromLch({ l: l, c: lch.c, h: lch.h }, next.a);
}
return out;
}
// Looks at an image's pixels: does it have see-through areas, and would
// what it draws there disappear against bg? 'unknown' when the pixels can't
// be read (a cross-origin image without CORS).
function judgeImagePixels(source, doc, bg) {
var w = source.naturalWidth, h = source.naturalHeight;
if (!w || !h) return 'fine';
var scale = Math.min(1, 48 / Math.max(w, h));
var cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale));
var canvas = doc.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
var ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return 'unknown';
var data;
try {
ctx.drawImage(source, 0, 0, cw, ch);
data = ctx.getImageData(0, 0, cw, ch).data;
} catch (e) {
return 'unknown';
}
var clear = 0, drawn = 0, hidden = 0;
for (var i = 0; i < data.length; i += 4) {
var a = data[i + 3] / 255;
if (a < 0.5) { clear++; continue; }
drawn++;
var px = composite({ r: data[i], g: data[i + 1], b: data[i + 2], a: a }, bg);
if (contrastRatio(px, bg) < 2) hidden++;
}
var total = data.length / 4;
// Opaque images cover any plate; mostly-readable artwork doesn't need one.
if (clear / total < 0.02 || drawn === 0) return 'fine';
return hidden / drawn > 0.35 ? 'plate' : 'fine';
}
function whenLoaded(img) {
if (img.complete) return Promise.resolve(img.naturalWidth > 0);
return new Promise(function (resolve) {
img.addEventListener('load', function () { resolve(true); }, { once: true });
img.addEventListener('error', function () { resolve(false); }, { once: true });
});
}
// Loads a second copy of a remote image with CORS so its pixels can be read.
function loadCorsCopy(img, doc) {
var src = img.currentSrc || img.src;
if (!/^https?:/i.test(src)) return Promise.resolve(null);
var copy = doc.createElement('img');
copy.crossOrigin = 'anonymous';
copy.referrerPolicy = 'no-referrer';
var loaded = new Promise(function (resolve) {
var timer = setTimeout(function () { resolve(null); }, 8000);
copy.onload = function () { clearTimeout(timer); resolve(copy); };
copy.onerror = function () { clearTimeout(timer); resolve(null); };
});
copy.src = src;
return loaded;
}
function plateIfNeeded(img, surface, options) {
return whenLoaded(img).then(function (ok) {
if (!ok) return;
var doc = img.ownerDocument;
// Spacers and tracking pixels.
if (img.naturalWidth <= 2 && img.naturalHeight <= 2) return;
var verdict = judgeImagePixels(img, doc, surface.bg);
var next = verdict === 'unknown' && options.remoteContentAllowed
? loadCorsCopy(img, doc).then(function (copy) {
return copy ? judgeImagePixels(copy, doc, surface.bg) : verdict;
})
: Promise.resolve(verdict);
return next.then(function (v) {
// Pixels we can't read get the plate: a light box around a logo is
// better than a logo that isn't there.
if (v === 'fine' || !img.isConnected) return;
img.style.setProperty('background-color', formatColor(surface.original), 'important');
});
}).catch(function () {});
}
function each(list, fn) {
Array.prototype.forEach.call(list, fn);
}
// Converts a rendered mail document to dark colours in place. Call once,
// after the document has laid out; image plates are decided once each
// image has loaded, after this returns.
function apply(doc, options) {
options = options || {};
var win = doc.defaultView;
var body = doc.body;
try {
if (!win || !body) return;
// Un-pin the canvas to read the mail's own; nothing paints before the
// writes below, so the light original never shows.
var html = doc.documentElement;
html.classList.remove('bulwark-dark-pending');
var ours = { r: 20, g: 20, b: 20, a: 1 };
var canvas = parseComputedColor(win.getComputedStyle(html).backgroundColor);
// A mail that paints <html> itself gets that canvas darkened like any
// other fill; left alone it shows wherever the body doesn't cover it.
var mailCanvas = canvas && canvas.a === 1 && !sameColor(canvas, ours) ? canvas : null;
var base = mailCanvas ? darkenBackground(mailCanvas) : ours;
// Read everything first, then write: interleaving would force a style
// recalc per element.
var elements = [body];
each(body.querySelectorAll('*'), function (el) {
if (!SKIP_TAGS[el.tagName] && !el.closest('svg')) elements.push(el);
});
var snaps = elements.map(function (el) {
var cs = win.getComputedStyle(el);
return {
el: el,
color: parseComputedColor(cs.color),
bg: parseComputedColor(cs.backgroundColor),
bgImage: cs.backgroundImage,
borders: BORDER_SIDES.map(function (side) {
return cs.getPropertyValue('border-' + side + '-style') !== 'none'
&& parseFloat(cs.getPropertyValue('border-' + side + '-width')) > 0
? parseComputedColor(cs.getPropertyValue('border-' + side + '-color'))
: null;
})
};
});
// The original canvas is the mail's own or white: that is what the
// light view paints.
var root = { bg: base, original: mailCanvas || WHITE, onImage: false };
var surfaces = new Map();
var surfaceOf = function (el) {
for (var n = el; n; n = n.parentElement) {
var s = surfaces.get(n);
if (s) return s;
}
return root;
};
var writes = [];
if (mailCanvas && base !== mailCanvas) {
writes.push(function () { html.style.setProperty('background-color', formatColor(base), 'important'); });
}
snaps.forEach(function (snap) {
var el = snap.el;
var parent = el === body ? root : surfaceOf(el.parentElement);
var surface = parent;
if (hasImage(snap.bgImage)) {
// Picture background: keep the element's own fill (it shows
// wherever the picture is transparent or fails to load) and its
// text as sent.
var fill = snap.bg && snap.bg.a > 0 ? composite(snap.bg, parent.bg) : parent.bg;
surface = { bg: fill, original: fill, onImage: true };
} else {
if (snap.bgImage && snap.bgImage !== 'none' && !parent.onImage) {
var gradient = mapGradientColors(snap.bgImage, darkenBackground);
if (gradient !== snap.bgImage) {
writes.push(function () { el.style.setProperty('background-image', gradient, 'important'); });
}
}
// A translucent box over a picture still shows the picture, so it
// keeps its colours; an opaque one is a surface of its own.
if (el === body && snap.bg && sameColor(snap.bg, ours)) {
// Our own dark canvas (emailDarkModeCss), not a fill the sender
// chose: in the light view this is the white page.
} else if (snap.bg && snap.bg.a > 0 && !(parent.onImage && snap.bg.a < 0.9)) {
var original = snap.bg;
// The body has no surroundings to stand out from.
var nextBg = parent.onImage || el === body
? darkenBackground(original)
: keepSeparation(darkenBackground(original), original, parent);
surface = {
bg: composite(nextBg, parent.bg),
original: composite(original, parent.original),
onImage: false
};
if (nextBg !== original) {
writes.push(function () { el.style.setProperty('background-color', formatColor(nextBg), 'important'); });
}
}
}
surfaces.set(el, surface);
if (surface.onImage) return;
if (snap.color) {
var nextColor = ensureContrast(snap.color, surface.bg, 4.5);
if (nextColor !== snap.color) {
writes.push(function () { el.style.setProperty('color', formatColor(nextColor), 'important'); });
}
}
snap.borders.forEach(function (border, i) {
if (!border || border.a === 0) return;
// Borders sit between the element and its parent; a light rule on a
// light mail darkens with the backgrounds, a dark one is lifted
// just enough to stay visible.
var darkened = darkenBackground(border);
var nextBorder = darkened !== border ? darkened : ensureContrast(border, parent.bg, 1.6);
if (nextBorder !== border) {
writes.push(function () {
el.style.setProperty('border-' + BORDER_SIDES[i] + '-color', formatColor(nextBorder), 'important');
});
}
});
});
// Images keep their pixels, but a transparent one (a black logo, a dark
// icon) was drawn for the light background it sat on and can vanish on
// the darkened one. Those get that original background back behind them.
var plates = [];
each(body.querySelectorAll('img'), function (img) {
var surface = surfaceOf(img.parentElement);
if (surface.onImage || surface.bg === surface.original) return;
if (toLch(surface.original).l < 0.7 || contrastRatio(surface.original, surface.bg) < 3) return;
plates.push({ img: img, surface: surface });
});
writes.forEach(function (write) { write(); });
plates.forEach(function (p) { plateIfNeeded(p.img, p.surface, options); });
} finally {
doc.documentElement.classList.remove('bulwark-dark-pending');
}
}
return {
parseComputedColor: parseComputedColor,
formatColor: formatColor,
contrastRatio: contrastRatio,
darkenBackground: darkenBackground,
ensureContrast: ensureContrast,
hasImage: hasImage,
apply: apply
};
})`;
/**
* The script the body view injects into an HTML mail shown dark: runs the
* pass once per document (the view injects its scripts again after load) and
* asks for a fresh height report.
*/
export function buildEmailDarkModeScript(options: EmailDarkModeOptions = {}): string {
const opts = JSON.stringify({ remoteContentAllowed: !!options.remoteContentAllowed });
return `
(function () {
if (document.__rnDarkRecolorDone || !document.body) return;
document.__rnDarkRecolorDone = true;
try {
(${EMAIL_DARK_MODE_LIB})().apply(document, ${opts});
} catch (e) {
document.documentElement.classList.remove('${DARK_PENDING_CLASS}');
}
if (window.__rnReport) setTimeout(window.__rnReport, 0);
})();
true;
`;
}
+24 -57
View File
@@ -21,6 +21,8 @@
// class-based colours (native #46/#49). When external content is blocked the
// sheet is scrubbed of url()/@import/@font-face references instead.
import { DARK_PENDING_CLASS, emailColorScheme, emailDarkModeCss, resolveColorSchemeMedia } from './email-dark-mode';
const FORBIDDEN_TAGS = [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'textarea', 'select', 'option',
@@ -675,9 +677,9 @@ export function hasRemoteContent(html: string): boolean {
// ─── Styles ──────────────────────────────────────────────────────────────
// Base styles for HTML emails - mirrors the webmail iframe body.
// Emails are authored for light mode; we render them true-to-life and apply a
// filter inversion trick for dark mode (unless the email has native dark
// support via @media (prefers-color-scheme: dark)).
// Emails are authored for light mode; they are rendered true-to-life, and in
// dark mode their colours are rewritten after layout by the injected pass in
// ./email-dark-mode.
function baseStyles(bodyPadding: string): string {
return `
html { background: #ffffff; height: auto !important; }
@@ -731,46 +733,6 @@ body { line-height: 1.15; }
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
`;
// Applied when the host is in dark mode and the email does NOT have native
// dark-mode CSS. Matches the webmail's darkModeCSS verbatim.
//
// 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 colour containers must NOT stack another
// invert either - each filter toggles the inversion, so an odd number of
// stacked filters lands back on light-on-light; the descendant rule disables
// filter on a colour container nested inside another.
//
// Background-IMAGE containers are different: a real photo and whatever is
// composited over it 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. Re-invert those unconditionally (no :has guard) and
// cancel the per-image re-invert for media inside them.
const DARK_INVERSION = `
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;
}
`;
// Matches the webmail's `emailHasNativeDarkMode` detector: true if the HTML
// contains a `@media (prefers-color-scheme: dark)` rule.
export function hasNativeDarkMode(html: string): boolean {
@@ -855,16 +817,16 @@ export interface WrapOptions {
/** Neutralise external resources and switch to the strict CSP. */
blockRemoteImages?: boolean;
cidMap?: Record<string, string>;
/** When true, the host renders in dark mode; inversion is applied unless
* the email already has native dark-mode CSS. */
/** When true, the host renders in dark mode: the head gets the dark
* fallback colours and the body stays hidden until the recolour pass ran. */
isDark?: boolean;
messageSpacing?: MessageSpacing;
}
export interface PreparedEmailHtml {
html: string;
/** Inversion CSS is in effect; the DOM re-invert pass should run. */
applyInversion: boolean;
/** Shown dark: the recolour pass (./email-dark-mode) has to run. */
recolorDark: boolean;
/** The email ships its own `prefers-color-scheme: dark` rules. */
hasNativeDark: boolean;
/** At least one external resource was neutralised. */
@@ -897,10 +859,10 @@ function buildCsp(strict: boolean): string {
}
/**
* Turn a raw HTML body into a complete, sandboxed document. `hasNativeDark`
* and `applyInversion` are evaluated once on the sanitised input and returned
* so the caller injects the matching DOM pass (a mismatch between the CSS
* decision and the script decision was native #49).
* Turn a raw HTML body into a complete, sandboxed document. `recolorDark` is
* returned so the caller injects the recolour pass exactly when the head
* hides the body for it (a mismatch between the CSS decision and the script
* decision was native #49).
*/
export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}): PreparedEmailHtml {
const { blockRemoteImages = false, cidMap, isDark = true, messageSpacing = 'auto' } = options;
@@ -915,9 +877,14 @@ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}):
}
const hasNativeDark = hasNativeDarkMode(processed);
const applyInversion = isDark && !hasNativeDark;
const colorScheme = isDark && hasNativeDark ? 'light dark' : 'light';
const darkCss = applyInversion ? DARK_INVERSION : '';
// The recolour pass runs on every mail shown dark, also one with its own
// dark styles: those are pinned to the app's theme below, and the pass
// leaves colours that are already dark or readable alone (webmail
// email-viewer.tsx recolorDark).
const recolorDark = isDark;
const colorScheme = emailColorScheme(isDark, hasNativeDark);
const darkCss = recolorDark ? emailDarkModeCss() : '';
const rootClass = recolorDark ? ` class="${DARK_PENDING_CLASS}"` : '';
const word = isWordHtml(processed);
const hasStyleTag = /<style\b/i.test(processed);
@@ -929,7 +896,7 @@ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}):
const bodyDir = declaresOwnDirection(processed) ? '' : ' dir="auto"';
const html = `<!doctype html>
<html style="color-scheme: ${colorScheme};">
<html${rootClass} style="color-scheme: ${colorScheme};">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
@@ -937,10 +904,10 @@ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}):
<meta name="referrer" content="no-referrer">
<style>${baseStyles(bodyPadding)}${word ? WORD_HTML_CSS : ''}${darkCss}</style>
</head>
<body${bodyDir}>${processed}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body>
<body${bodyDir}>${resolveColorSchemeMedia(processed, isDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body>
</html>`;
return { html, applyInversion, hasNativeDark, blockedExternal };
return { html, recolorDark, hasNativeDark, blockedExternal };
}
export function wrapEmailHtml(innerHtml: string, options: WrapOptions = {}): string {
+2 -2
View File
@@ -57,8 +57,8 @@ export const QUOTE_COLLAPSED_ATTR = 'data-quote-collapsed';
/**
* The in-page implementation of `setupQuoteCollapse`, as a self-contained
* script for `injectedJavaScript`. Idempotent per document. Uses inline
* styles only (style-src allows inline; neutral greys read fine after the
* dark-mode invert filter too).
* styles only (style-src allows inline; the dark-mode recolour pass runs
* after it and converts the toggle like the rest of the mail).
*/
export function buildQuoteCollapseScript(labels: QuoteCollapseLabels): string {
const cfg = JSON.stringify({