Files
jmap-webmail/lib/color-transform.ts
T
Matthieu MALVACHEandClaude Sonnet 4.5 395f92c47f feat(email): add newsletter unsubscribe and enhance dark mode
Implement RFC 2369 List-Unsubscribe support with security validation (protocol whitelist, XSS prevention), two-step confirmation UI, and localStorage persistence. Add intelligent color transformation for dark mode to fix unreadable inline email styles using WCAG 2.0 luminance calculation. Complete batch spam undo operations for context menu multi-select. Update documentation with new features and comprehensive test coverage (97 tests).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-08 18:39:27 +01:00

210 lines
6.2 KiB
TypeScript

type RGB = { r: number; g: number; b: number; a?: number };
const namedColors: Record<string, string> = {
transparent: 'rgba(0,0,0,0)',
black: '#000000',
white: '#ffffff',
red: '#ff0000',
green: '#008000',
blue: '#0000ff',
yellow: '#ffff00',
cyan: '#00ffff',
magenta: '#ff00ff',
gray: '#808080',
grey: '#808080',
silver: '#c0c0c0',
maroon: '#800000',
olive: '#808000',
lime: '#00ff00',
aqua: '#00ffff',
teal: '#008080',
navy: '#000080',
fuchsia: '#ff00ff',
purple: '#800080',
};
export function parseColor(colorString: string): RGB | null {
if (!colorString || typeof colorString !== 'string') {
return null;
}
const color = colorString.trim().toLowerCase();
if (color === 'inherit' || color === 'currentcolor') {
return null;
}
if (namedColors[color]) {
return parseColor(namedColors[color]);
}
if (color === 'transparent') {
return { r: 0, g: 0, b: 0, a: 0 };
}
const hexMatch = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hexMatch) {
const hex = hexMatch[1];
if (hex.length === 3) {
return {
r: parseInt(hex[0] + hex[0], 16),
g: parseInt(hex[1] + hex[1], 16),
b: parseInt(hex[2] + hex[2], 16),
};
}
return {
r: parseInt(hex.substr(0, 2), 16),
g: parseInt(hex.substr(2, 2), 16),
b: parseInt(hex.substr(4, 2), 16),
};
}
const rgbMatch = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/);
if (rgbMatch) {
const r = parseInt(rgbMatch[1], 10);
const g = parseInt(rgbMatch[2], 10);
const b = parseInt(rgbMatch[3], 10);
const a = rgbMatch[4] ? parseFloat(rgbMatch[4]) : undefined;
if (r > 255 || g > 255 || b > 255 || r < 0 || g < 0 || b < 0) {
return null;
}
if (a !== undefined && (a < 0 || a > 1)) {
return null;
}
return { r, g, b, a };
}
const hslMatch = color.match(/^hsla?\((\d+),\s*([\d.]+)%,\s*([\d.]+)%(?:,\s*([\d.]+))?\)$/);
if (hslMatch) {
const h = parseInt(hslMatch[1], 10) / 360;
const s = parseFloat(hslMatch[2]) / 100;
const l = parseFloat(hslMatch[3]) / 100;
const a = hslMatch[4] ? parseFloat(hslMatch[4]) : undefined;
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return {
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
g: Math.round(hue2rgb(p, q, h) * 255),
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
a,
};
}
return null;
}
export function getLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
const val = c / 255;
return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
export function isDarkColor(colorString: string): boolean {
const rgb = parseColor(colorString);
if (!rgb) return false;
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
return luminance < 0.5;
}
export function transformColorForDarkMode(colorString: string): string {
const rgb = parseColor(colorString);
if (!rgb) return colorString;
if (rgb.a !== undefined && rgb.a < 0.1) {
return colorString;
}
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
if (luminance < 0.4) {
const invR = 255 - rgb.r;
const invG = 255 - rgb.g;
const invB = 255 - rgb.b;
const boost = 1.3;
const r = Math.min(255, Math.round(invR * boost));
const g = Math.min(255, Math.round(invG * boost));
const b = Math.min(255, Math.round(invB * boost));
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
}
if (luminance >= 0.4 && luminance < 0.6) {
const factor = 1.5;
const r = Math.min(255, Math.round(rgb.r + (255 - rgb.r) * factor * 0.4));
const g = Math.min(255, Math.round(rgb.g + (255 - rgb.g) * factor * 0.4));
const b = Math.min(255, Math.round(rgb.b + (255 - rgb.b) * factor * 0.4));
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
}
return colorString;
}
export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'): string {
if (theme !== 'dark' || !cssText) {
return cssText;
}
const styleProps = cssText.split(';').map((prop) => prop.trim()).filter(Boolean);
const transformedProps = styleProps.map((prop) => {
const colonIndex = prop.indexOf(':');
if (colonIndex === -1) return prop;
const property = prop.slice(0, colonIndex).trim();
const value = prop.slice(colonIndex + 1).trim();
if (property === 'color') {
const hasImportant = value.includes('!important');
const colorValue = value.replace('!important', '').trim();
const transformed = transformColorForDarkMode(colorValue);
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
}
if (property === 'background-color') {
const hasImportant = value.includes('!important');
const colorValue = value.replace('!important', '').trim();
const transformed = transformColorForDarkMode(colorValue);
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
}
if (property === 'background' && !value.includes('url(')) {
const colorMatch = value.match(/#[0-9a-f]{3,6}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-z]+/i);
if (colorMatch) {
const hasImportant = value.includes('!important');
const originalColor = colorMatch[0];
const transformed = transformColorForDarkMode(originalColor);
const newValue = value.replace(originalColor, transformed);
return `${property}: ${newValue.replace('!important', '').trim()}${hasImportant ? ' !important' : ''}`;
}
}
if (property === 'border-color') {
const hasImportant = value.includes('!important');
const colorValue = value.replace('!important', '').trim();
const transformed = transformColorForDarkMode(colorValue);
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
}
return prop;
});
return transformedProps.join('; ');
}