feat: expand internationalization and add identity management
This release significantly expands internationalization support and adds comprehensive identity management features. Internationalization (i18n): - Add 5 new languages: Spanish, Italian, German, Dutch, Portuguese - Expand from 3 to 8 total supported languages - Redesign language switcher for better scalability (dropdown UI) - Complete translations for all features across all languages Identity Management: - Multiple sender identities with per-identity signatures - Sub-addressing support (user+tag@domain.com) - Context-aware tag suggestions for sub-addresses - Identity badges in email viewer and list - Full CRUD operations for managing identities Newsletter Management: - RFC 2369 List-Unsubscribe support (one-click unsubscribe) - HTTP and mailto unsubscribe methods - Security validation prevents XSS attacks - Two-step confirmation with persistent dismissal Security & Accessibility: - Dark mode email readability (intelligent color transformation) - WCAG 2.0 Level AA color contrast compliance - Comprehensive XSS prevention with validation utilities - Unit test coverage for security-critical code (57 validation tests) Testing: - Add unit tests for validation utilities - Add unit tests for email sanitization - Add unit tests for color transformation - Full test coverage for XSS attack vectors
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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('; ');
|
||||
}
|
||||
Reference in New Issue
Block a user