mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
feat: recolour HTML mail for dark mode instead of inverting it
This commit is contained in:
@@ -6,6 +6,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|||||||
import { Image as ImageIcon, ShieldCheck } from './icons';
|
import { Image as ImageIcon, ShieldCheck } from './icons';
|
||||||
import type { Email } from '../api/types';
|
import type { Email } from '../api/types';
|
||||||
import { extractCidRefs, isUserLinkNavigation } from '../lib/email-html';
|
import { extractCidRefs, isUserLinkNavigation } from '../lib/email-html';
|
||||||
|
import { buildEmailDarkModeScript } from '../lib/email-dark-mode';
|
||||||
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
|
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
|
||||||
import { buildQuoteCollapseScript } from '../lib/quote-collapse';
|
import { buildQuoteCollapseScript } from '../lib/quote-collapse';
|
||||||
import { bodyDocument } from '../lib/email-body-document';
|
import { bodyDocument } from '../lib/email-body-document';
|
||||||
@@ -67,109 +68,6 @@ interface EmailBodyViewProps {
|
|||||||
onShowFormatted?: () => void;
|
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:
|
// Image fix-ups, mirroring the webmail's post-render passes:
|
||||||
// - collapse empty <td>/<div> wrappers left behind by blocked images so a
|
// - collapse empty <td>/<div> wrappers left behind by blocked images so a
|
||||||
// blocked newsletter doesn't show rows of empty cells;
|
// blocked newsletter doesn't show rows of empty cells;
|
||||||
@@ -569,8 +467,8 @@ export default function EmailBodyView({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
|
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||||||
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
|
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||||
// Most marketing email is authored against a white background, so dark-mode
|
// Most marketing email is authored against a white background, and some
|
||||||
// inversion can wreck logos/banners. With this flag the user opts to render
|
// 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
|
// emails on a light surface even while the rest of the app is dark; the
|
||||||
// per-message toggle in the More sheet overrides both.
|
// per-message toggle in the More sheet overrides both.
|
||||||
const renderAsDark = themeOverride
|
const renderAsDark = themeOverride
|
||||||
@@ -730,18 +628,21 @@ export default function EmailBodyView({
|
|||||||
// Still cut off after the API's larger refetch (or the refetch failed).
|
// Still cut off after the API's larger refetch (or the refetch failed).
|
||||||
const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email);
|
const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email);
|
||||||
|
|
||||||
// Inversion is applied for HTML bodies in dark mode unless the email ships
|
// An HTML body shown dark has its colours rewritten after layout (webmail
|
||||||
// its own dark-mode CSS - decided once by prepareEmailHtml on the same
|
// applyEmailDarkMode): decided once by prepareEmailHtml, whose head hides
|
||||||
// input the CSS was built from, so the DOM pass and the stylesheet agree.
|
// 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(
|
const injectedJs = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
(prepared.isHtml ? buildMailTextSizeScript(textFactor) : '')
|
(prepared.isHtml ? buildMailTextSizeScript(textFactor) : '')
|
||||||
+ (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
+ (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
||||||
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
|
|
||||||
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
|
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
|
||||||
+ (prepared.isHtml && reduceMotion ? buildPauseAnimatedImagesScript(pauseLabels) : '')
|
+ (prepared.isHtml && reduceMotion ? buildPauseAnimatedImagesScript(pauseLabels) : '')
|
||||||
|
+ (prepared.recolorDark ? buildEmailDarkModeScript({ remoteContentAllowed: !shouldBlock }) : '')
|
||||||
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
|
+ 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.
|
// Performance harness: how much of opening a message is the WebView.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -254,21 +254,39 @@ describe('prepareEmailHtml', () => {
|
|||||||
expect(prepareEmailHtml('<p>x</p>').html).not.toMatch(/<script/i);
|
expect(prepareEmailHtml('<p>x</p>').html).not.toMatch(/<script/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('evaluates native dark mode once and reports the inversion decision', () => {
|
// Dark mode rewrites the mail's colours after layout (lib/email-dark-mode)
|
||||||
const native = prepareEmailHtml('<style>@media (prefers-color-scheme: dark){body{color:#fff}}</style><p>x</p>', { isDark: true });
|
// instead of inverting the body with a filter, as the webmail does since
|
||||||
expect(native.hasNativeDark).toBe(true);
|
// 7401ea665: the head only sets fallback colours and hides the body until
|
||||||
expect(native.applyInversion).toBe(false);
|
// the pass ran.
|
||||||
expect(native.html).toContain('color-scheme: light dark');
|
it('recolours a mail shown dark instead of inverting it', () => {
|
||||||
expect(native.html).not.toContain('invert(1)');
|
|
||||||
|
|
||||||
const plain = prepareEmailHtml('<p>x</p>', { isDark: true });
|
const plain = prepareEmailHtml('<p>x</p>', { isDark: true });
|
||||||
expect(plain.applyInversion).toBe(true);
|
expect(plain.recolorDark).toBe(true);
|
||||||
expect(plain.html).toContain('body { filter: invert(1) hue-rotate(180deg)');
|
expect(plain.html).not.toContain('invert(1)');
|
||||||
// Current webmail revision: background-image containers re-invert without the :has guard.
|
expect(plain.html).toContain('<html class="bulwark-dark-pending"');
|
||||||
expect(plain.html).toMatch(/\[style\*="background-image"\],\s*\[background\]\s*\{\s*filter: invert\(1\)/);
|
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 });
|
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', () => {
|
it('reports blocked external content', () => {
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export interface BodyDocumentInput {
|
|||||||
|
|
||||||
export interface BodyDocument {
|
export interface BodyDocument {
|
||||||
html: string;
|
html: string;
|
||||||
/** Inversion CSS is in effect; the DOM re-invert pass should run. */
|
/** Shown dark: the recolour pass (lib/email-dark-mode) has to run. */
|
||||||
applyInversion: boolean;
|
recolorDark: boolean;
|
||||||
hasNativeDark: boolean;
|
hasNativeDark: boolean;
|
||||||
/** At least one external resource was neutralised. */
|
/** At least one external resource was neutralised. */
|
||||||
blockedExternal: boolean;
|
blockedExternal: boolean;
|
||||||
@@ -69,7 +69,7 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
|
|||||||
fontPx: mailBaseFontPx(input.textFactor ?? 1),
|
fontPx: mailBaseFontPx(input.textFactor ?? 1),
|
||||||
darkColors: input.plainDarkColors,
|
darkColors: input.plainDarkColors,
|
||||||
}),
|
}),
|
||||||
applyInversion: false,
|
recolorDark: false,
|
||||||
hasNativeDark: false,
|
hasNativeDark: false,
|
||||||
blockedExternal: false,
|
blockedExternal: false,
|
||||||
isHtml: false,
|
isHtml: false,
|
||||||
|
|||||||
@@ -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
@@ -21,6 +21,8 @@
|
|||||||
// class-based colours (native #46/#49). When external content is blocked the
|
// class-based colours (native #46/#49). When external content is blocked the
|
||||||
// sheet is scrubbed of url()/@import/@font-face references instead.
|
// sheet is scrubbed of url()/@import/@font-face references instead.
|
||||||
|
|
||||||
|
import { DARK_PENDING_CLASS, emailColorScheme, emailDarkModeCss, resolveColorSchemeMedia } from './email-dark-mode';
|
||||||
|
|
||||||
const FORBIDDEN_TAGS = [
|
const FORBIDDEN_TAGS = [
|
||||||
'script', 'iframe', 'object', 'embed', 'form',
|
'script', 'iframe', 'object', 'embed', 'form',
|
||||||
'input', 'button', 'textarea', 'select', 'option',
|
'input', 'button', 'textarea', 'select', 'option',
|
||||||
@@ -675,9 +677,9 @@ export function hasRemoteContent(html: string): boolean {
|
|||||||
// ─── Styles ──────────────────────────────────────────────────────────────
|
// ─── Styles ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Base styles for HTML emails - mirrors the webmail iframe body.
|
// 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
|
// Emails are authored for light mode; they are rendered true-to-life, and in
|
||||||
// filter inversion trick for dark mode (unless the email has native dark
|
// dark mode their colours are rewritten after layout by the injected pass in
|
||||||
// support via @media (prefers-color-scheme: dark)).
|
// ./email-dark-mode.
|
||||||
function baseStyles(bodyPadding: string): string {
|
function baseStyles(bodyPadding: string): string {
|
||||||
return `
|
return `
|
||||||
html { background: #ffffff; height: auto !important; }
|
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; }
|
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
|
// Matches the webmail's `emailHasNativeDarkMode` detector: true if the HTML
|
||||||
// contains a `@media (prefers-color-scheme: dark)` rule.
|
// contains a `@media (prefers-color-scheme: dark)` rule.
|
||||||
export function hasNativeDarkMode(html: string): boolean {
|
export function hasNativeDarkMode(html: string): boolean {
|
||||||
@@ -855,16 +817,16 @@ export interface WrapOptions {
|
|||||||
/** Neutralise external resources and switch to the strict CSP. */
|
/** Neutralise external resources and switch to the strict CSP. */
|
||||||
blockRemoteImages?: boolean;
|
blockRemoteImages?: boolean;
|
||||||
cidMap?: Record<string, string>;
|
cidMap?: Record<string, string>;
|
||||||
/** When true, the host renders in dark mode; inversion is applied unless
|
/** When true, the host renders in dark mode: the head gets the dark
|
||||||
* the email already has native dark-mode CSS. */
|
* fallback colours and the body stays hidden until the recolour pass ran. */
|
||||||
isDark?: boolean;
|
isDark?: boolean;
|
||||||
messageSpacing?: MessageSpacing;
|
messageSpacing?: MessageSpacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreparedEmailHtml {
|
export interface PreparedEmailHtml {
|
||||||
html: string;
|
html: string;
|
||||||
/** Inversion CSS is in effect; the DOM re-invert pass should run. */
|
/** Shown dark: the recolour pass (./email-dark-mode) has to run. */
|
||||||
applyInversion: boolean;
|
recolorDark: boolean;
|
||||||
/** The email ships its own `prefers-color-scheme: dark` rules. */
|
/** The email ships its own `prefers-color-scheme: dark` rules. */
|
||||||
hasNativeDark: boolean;
|
hasNativeDark: boolean;
|
||||||
/** At least one external resource was neutralised. */
|
/** 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`
|
* Turn a raw HTML body into a complete, sandboxed document. `recolorDark` is
|
||||||
* and `applyInversion` are evaluated once on the sanitised input and returned
|
* returned so the caller injects the recolour pass exactly when the head
|
||||||
* so the caller injects the matching DOM pass (a mismatch between the CSS
|
* hides the body for it (a mismatch between the CSS decision and the script
|
||||||
* decision and the script decision was native #49).
|
* decision was native #49).
|
||||||
*/
|
*/
|
||||||
export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}): PreparedEmailHtml {
|
export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}): PreparedEmailHtml {
|
||||||
const { blockRemoteImages = false, cidMap, isDark = true, messageSpacing = 'auto' } = options;
|
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 hasNativeDark = hasNativeDarkMode(processed);
|
||||||
const applyInversion = isDark && !hasNativeDark;
|
// The recolour pass runs on every mail shown dark, also one with its own
|
||||||
const colorScheme = isDark && hasNativeDark ? 'light dark' : 'light';
|
// dark styles: those are pinned to the app's theme below, and the pass
|
||||||
const darkCss = applyInversion ? DARK_INVERSION : '';
|
// 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 word = isWordHtml(processed);
|
||||||
const hasStyleTag = /<style\b/i.test(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 bodyDir = declaresOwnDirection(processed) ? '' : ' dir="auto"';
|
||||||
|
|
||||||
const html = `<!doctype html>
|
const html = `<!doctype html>
|
||||||
<html style="color-scheme: ${colorScheme};">
|
<html${rootClass} style="color-scheme: ${colorScheme};">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
<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">
|
<meta name="referrer" content="no-referrer">
|
||||||
<style>${baseStyles(bodyPadding)}${word ? WORD_HTML_CSS : ''}${darkCss}</style>
|
<style>${baseStyles(bodyPadding)}${word ? WORD_HTML_CSS : ''}${darkCss}</style>
|
||||||
</head>
|
</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>`;
|
</html>`;
|
||||||
|
|
||||||
return { html, applyInversion, hasNativeDark, blockedExternal };
|
return { html, recolorDark, hasNativeDark, blockedExternal };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wrapEmailHtml(innerHtml: string, options: WrapOptions = {}): string {
|
export function wrapEmailHtml(innerHtml: string, options: WrapOptions = {}): string {
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ export const QUOTE_COLLAPSED_ATTR = 'data-quote-collapsed';
|
|||||||
/**
|
/**
|
||||||
* The in-page implementation of `setupQuoteCollapse`, as a self-contained
|
* The in-page implementation of `setupQuoteCollapse`, as a self-contained
|
||||||
* script for `injectedJavaScript`. Idempotent per document. Uses inline
|
* script for `injectedJavaScript`. Idempotent per document. Uses inline
|
||||||
* styles only (style-src allows inline; neutral greys read fine after the
|
* styles only (style-src allows inline; the dark-mode recolour pass runs
|
||||||
* dark-mode invert filter too).
|
* after it and converts the toggle like the rest of the mail).
|
||||||
*/
|
*/
|
||||||
export function buildQuoteCollapseScript(labels: QuoteCollapseLabels): string {
|
export function buildQuoteCollapseScript(labels: QuoteCollapseLabels): string {
|
||||||
const cfg = JSON.stringify({
|
const cfg = JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user