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,296 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseColor,
|
||||
getLuminance,
|
||||
isDarkColor,
|
||||
transformColorForDarkMode,
|
||||
transformInlineStyles,
|
||||
} from '../color-transform';
|
||||
|
||||
describe('parseColor', () => {
|
||||
describe('hex colors', () => {
|
||||
it('should parse 6-digit hex colors', () => {
|
||||
expect(parseColor('#333333')).toEqual({ r: 51, g: 51, b: 51 });
|
||||
expect(parseColor('#AABBCC')).toEqual({ r: 170, g: 187, b: 204 });
|
||||
expect(parseColor('#ffffff')).toEqual({ r: 255, g: 255, b: 255 });
|
||||
});
|
||||
|
||||
it('should parse 3-digit hex colors', () => {
|
||||
expect(parseColor('#FFF')).toEqual({ r: 255, g: 255, b: 255 });
|
||||
expect(parseColor('#ABC')).toEqual({ r: 170, g: 187, b: 204 });
|
||||
expect(parseColor('#000')).toEqual({ r: 0, g: 0, b: 0 });
|
||||
});
|
||||
|
||||
it('should handle uppercase and lowercase', () => {
|
||||
expect(parseColor('#aabbcc')).toEqual(parseColor('#AABBCC'));
|
||||
expect(parseColor('#fff')).toEqual(parseColor('#FFF'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('rgb/rgba colors', () => {
|
||||
it('should parse rgb colors', () => {
|
||||
expect(parseColor('rgb(51, 51, 51)')).toEqual({ r: 51, g: 51, b: 51 });
|
||||
expect(parseColor('rgb(255, 0, 0)')).toEqual({ r: 255, g: 0, b: 0 });
|
||||
});
|
||||
|
||||
it('should parse rgba colors', () => {
|
||||
expect(parseColor('rgba(51, 51, 51, 0.5)')).toEqual({ r: 51, g: 51, b: 51, a: 0.5 });
|
||||
expect(parseColor('rgba(0, 0, 0, 0.8)')).toEqual({ r: 0, g: 0, b: 0, a: 0.8 });
|
||||
});
|
||||
|
||||
it('should handle spaces in rgb/rgba', () => {
|
||||
expect(parseColor('rgb(51,51,51)')).toEqual({ r: 51, g: 51, b: 51 });
|
||||
expect(parseColor('rgba(51, 51, 51, 0.5)')).toEqual({ r: 51, g: 51, b: 51, a: 0.5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hsl/hsla colors', () => {
|
||||
it('should parse hsl colors', () => {
|
||||
const result = parseColor('hsl(0, 0%, 20%)');
|
||||
expect(result).toEqual({ r: 51, g: 51, b: 51 });
|
||||
});
|
||||
|
||||
it('should parse hsla colors', () => {
|
||||
const result = parseColor('hsla(0, 0%, 20%, 0.5)');
|
||||
expect(result).toEqual({ r: 51, g: 51, b: 51, a: 0.5 });
|
||||
});
|
||||
|
||||
it('should convert hsl to rgb correctly', () => {
|
||||
const red = parseColor('hsl(0, 100%, 50%)');
|
||||
expect(red).toEqual({ r: 255, g: 0, b: 0 });
|
||||
|
||||
const green = parseColor('hsl(120, 100%, 50%)');
|
||||
expect(green).toEqual({ r: 0, g: 255, b: 0 });
|
||||
|
||||
const blue = parseColor('hsl(240, 100%, 50%)');
|
||||
expect(blue).toEqual({ r: 0, g: 0, b: 255 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('named colors', () => {
|
||||
it('should parse named colors', () => {
|
||||
expect(parseColor('black')).toEqual({ r: 0, g: 0, b: 0 });
|
||||
expect(parseColor('white')).toEqual({ r: 255, g: 255, b: 255 });
|
||||
expect(parseColor('red')).toEqual({ r: 255, g: 0, b: 0 });
|
||||
expect(parseColor('green')).toEqual({ r: 0, g: 128, b: 0 });
|
||||
expect(parseColor('blue')).toEqual({ r: 0, g: 0, b: 255 });
|
||||
});
|
||||
|
||||
it('should handle transparent', () => {
|
||||
expect(parseColor('transparent')).toEqual({ r: 0, g: 0, b: 0, a: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return null for invalid colors', () => {
|
||||
expect(parseColor('invalid')).toBeNull();
|
||||
expect(parseColor('#GGGGGG')).toBeNull();
|
||||
expect(parseColor('rgb(300, 400, 500)')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for inherit and currentColor', () => {
|
||||
expect(parseColor('inherit')).toBeNull();
|
||||
expect(parseColor('currentColor')).toBeNull();
|
||||
expect(parseColor('currentcolor')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty or invalid input', () => {
|
||||
expect(parseColor('')).toBeNull();
|
||||
expect(parseColor(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLuminance', () => {
|
||||
it('should calculate luminance for black', () => {
|
||||
expect(getLuminance(0, 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate luminance for white', () => {
|
||||
expect(getLuminance(255, 255, 255)).toBe(1);
|
||||
});
|
||||
|
||||
it('should calculate luminance for gray', () => {
|
||||
const luminance = getLuminance(128, 128, 128);
|
||||
expect(luminance).toBeGreaterThan(0);
|
||||
expect(luminance).toBeLessThan(1);
|
||||
expect(luminance).toBeCloseTo(0.215, 2);
|
||||
});
|
||||
|
||||
it('should calculate luminance for dark colors', () => {
|
||||
const darkGray = getLuminance(51, 51, 51);
|
||||
expect(darkGray).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it('should calculate luminance for light colors', () => {
|
||||
const lightGray = getLuminance(200, 200, 200);
|
||||
expect(lightGray).toBeGreaterThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDarkColor', () => {
|
||||
it('should identify dark colors', () => {
|
||||
expect(isDarkColor('#000000')).toBe(true);
|
||||
expect(isDarkColor('#111111')).toBe(true);
|
||||
expect(isDarkColor('#333333')).toBe(true);
|
||||
expect(isDarkColor('rgb(51, 51, 51)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify light colors', () => {
|
||||
expect(isDarkColor('#ffffff')).toBe(false);
|
||||
expect(isDarkColor('#eeeeee')).toBe(false);
|
||||
expect(isDarkColor('rgb(200, 200, 200)')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for invalid colors', () => {
|
||||
expect(isDarkColor('invalid')).toBe(false);
|
||||
expect(isDarkColor('inherit')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformColorForDarkMode', () => {
|
||||
it('should lighten very dark colors', () => {
|
||||
const original = '#111111';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
const originalRgb = parseColor(original)!;
|
||||
const transformedRgb = parseColor(transformed)!;
|
||||
|
||||
expect(transformedRgb.r).toBeGreaterThan(originalRgb.r);
|
||||
expect(transformedRgb.g).toBeGreaterThan(originalRgb.g);
|
||||
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
|
||||
});
|
||||
|
||||
it('should transform #333333 to a lighter color', () => {
|
||||
const transformed = transformColorForDarkMode('#333333');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeGreaterThan(0.4);
|
||||
});
|
||||
|
||||
it('should preserve already light colors', () => {
|
||||
const lightColors = ['#eeeeee', '#ffffff', 'rgb(200, 200, 200)'];
|
||||
lightColors.forEach((color) => {
|
||||
const original = parseColor(color)!;
|
||||
const transformed = parseColor(transformColorForDarkMode(color))!;
|
||||
const originalLum = getLuminance(original.r, original.g, original.b);
|
||||
const transformedLum = getLuminance(transformed.r, transformed.g, transformed.b);
|
||||
|
||||
expect(transformedLum).toBeGreaterThanOrEqual(originalLum * 0.9);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle rgba colors with alpha', () => {
|
||||
const original = 'rgba(51, 51, 51, 0.8)';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
expect(transformed).toContain('rgba');
|
||||
expect(transformed).toContain('0.8');
|
||||
});
|
||||
|
||||
it('should preserve nearly transparent colors', () => {
|
||||
const original = 'rgba(0, 0, 0, 0.05)';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
expect(transformed).toBe(original);
|
||||
});
|
||||
|
||||
it('should handle invalid colors gracefully', () => {
|
||||
expect(transformColorForDarkMode('invalid')).toBe('invalid');
|
||||
expect(transformColorForDarkMode('inherit')).toBe('inherit');
|
||||
});
|
||||
|
||||
it('should lighten medium darkness colors', () => {
|
||||
const original = '#646463';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
const originalRgb = parseColor(original)!;
|
||||
const transformedRgb = parseColor(transformed)!;
|
||||
|
||||
expect(transformedRgb.r).toBeGreaterThan(originalRgb.r);
|
||||
expect(transformedRgb.g).toBeGreaterThan(originalRgb.g);
|
||||
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformInlineStyles', () => {
|
||||
it('should not transform styles in light mode', () => {
|
||||
const original = 'color: #333333; font-size: 16px';
|
||||
expect(transformInlineStyles(original, 'light')).toBe(original);
|
||||
});
|
||||
|
||||
it('should transform color property in dark mode', () => {
|
||||
const original = 'color: #333333';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('color:');
|
||||
expect(transformed).toContain('rgb(');
|
||||
});
|
||||
|
||||
it('should transform background-color property', () => {
|
||||
const original = 'background-color: #111111';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('background-color:');
|
||||
});
|
||||
|
||||
it('should preserve non-color properties', () => {
|
||||
const original = 'color: #333333; font-size: 16px; margin: 10px';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toContain('font-size: 16px');
|
||||
expect(transformed).toContain('margin: 10px');
|
||||
});
|
||||
|
||||
it('should handle multiple color properties', () => {
|
||||
const original = 'color: #111111; background-color: #222222; font-weight: bold';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toContain('color:');
|
||||
expect(transformed).toContain('background-color:');
|
||||
expect(transformed).toContain('font-weight: bold');
|
||||
});
|
||||
|
||||
it('should preserve !important declarations', () => {
|
||||
const original = 'color: #333333 !important';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toContain('!important');
|
||||
});
|
||||
|
||||
it('should handle empty or invalid styles', () => {
|
||||
expect(transformInlineStyles('', 'dark')).toBe('');
|
||||
expect(transformInlineStyles('invalid', 'dark')).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should transform the James Clear email colors', () => {
|
||||
const original = 'color: #333333; font-family: Georgia; font-size: 16px';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
|
||||
expect(transformed).toContain('font-family: Georgia');
|
||||
expect(transformed).toContain('font-size: 16px');
|
||||
|
||||
const colorMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
expect(colorMatch).not.toBeNull();
|
||||
|
||||
if (colorMatch) {
|
||||
const [, r, g, b] = colorMatch.map(Number);
|
||||
expect(r).toBeGreaterThan(51);
|
||||
expect(g).toBeGreaterThan(51);
|
||||
expect(b).toBeGreaterThan(51);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle background shorthand with color', () => {
|
||||
const original = 'background: #333333';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('background:');
|
||||
});
|
||||
|
||||
it('should not transform background with url', () => {
|
||||
const original = 'background: url(image.jpg) #333333';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toBe(original);
|
||||
});
|
||||
|
||||
it('should transform border-color', () => {
|
||||
const original = 'border-color: #111111';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('border-color:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
sanitizeEmailHtml,
|
||||
sanitizeSignatureHtml,
|
||||
parseHtmlSafely,
|
||||
hasRichFormatting,
|
||||
} from '../email-sanitization';
|
||||
|
||||
describe('email-sanitization', () => {
|
||||
describe('sanitizeEmailHtml', () => {
|
||||
it('should remove script tags', () => {
|
||||
const malicious = '<p>Hello</p><script>alert("XSS")</script>';
|
||||
const clean = sanitizeEmailHtml(malicious);
|
||||
expect(clean).not.toContain('<script>');
|
||||
expect(clean).toContain('Hello');
|
||||
});
|
||||
|
||||
it('should remove event handlers', () => {
|
||||
const malicious = '<img src="x" onerror="alert(\'XSS\')">';
|
||||
const clean = sanitizeEmailHtml(malicious);
|
||||
expect(clean).not.toContain('onerror');
|
||||
});
|
||||
|
||||
it('should remove iframe, object, embed tags', () => {
|
||||
const malicious = '<div>Content</div><iframe src="evil.com"></iframe><object></object>';
|
||||
const clean = sanitizeEmailHtml(malicious);
|
||||
expect(clean).not.toContain('<iframe');
|
||||
expect(clean).not.toContain('<object');
|
||||
expect(clean).toContain('Content');
|
||||
});
|
||||
|
||||
it('should remove meta, link, base tags', () => {
|
||||
const malicious = '<p>Text</p><meta charset="utf-8"><link rel="stylesheet" href="evil.css">';
|
||||
const clean = sanitizeEmailHtml(malicious);
|
||||
expect(clean).not.toContain('<meta');
|
||||
expect(clean).not.toContain('<link');
|
||||
expect(clean).toContain('Text');
|
||||
});
|
||||
|
||||
it('should preserve safe HTML structure', () => {
|
||||
const safe = '<p>Paragraph</p><div><span>Nested</span></div><table><tr><td>Cell</td></tr></table>';
|
||||
const clean = sanitizeEmailHtml(safe);
|
||||
expect(clean).toContain('<p>');
|
||||
expect(clean).toContain('<div>');
|
||||
expect(clean).toContain('<table>');
|
||||
expect(clean).toContain('Cell');
|
||||
});
|
||||
|
||||
it('should preserve safe attributes', () => {
|
||||
const withAttrs = '<p style="color: red;" class="text">Styled</p>';
|
||||
const clean = sanitizeEmailHtml(withAttrs);
|
||||
expect(clean).toContain('style');
|
||||
expect(clean).toContain('class');
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
expect(sanitizeEmailHtml('')).toBe('');
|
||||
expect(sanitizeEmailHtml(' ')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle malformed HTML', () => {
|
||||
const malformed = '<p>Unclosed<div>Tags';
|
||||
const clean = sanitizeEmailHtml(malformed);
|
||||
expect(clean).toContain('Unclosed');
|
||||
expect(clean).toContain('Tags');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeSignatureHtml', () => {
|
||||
it('should allow basic formatting tags', () => {
|
||||
const signature = '<p><strong>John Doe</strong><br><em>Software Engineer</em></p>';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).toContain('<strong>');
|
||||
expect(clean).toContain('<em>');
|
||||
expect(clean).toContain('John Doe');
|
||||
});
|
||||
|
||||
it('should remove images from signatures', () => {
|
||||
const signature = '<p>John</p><img src="logo.png" alt="Logo">';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('<img');
|
||||
expect(clean).toContain('John');
|
||||
});
|
||||
|
||||
it('should remove video and audio tags', () => {
|
||||
const signature = '<p>John</p><video src="vid.mp4"></video><audio src="sound.mp3"></audio>';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).not.toContain('<video');
|
||||
expect(clean).not.toContain('<audio');
|
||||
});
|
||||
|
||||
it('should preserve links with safe attributes', () => {
|
||||
const signature = '<p><a href="https://example.com" style="color: blue;">Website</a></p>';
|
||||
const clean = sanitizeSignatureHtml(signature);
|
||||
expect(clean).toContain('<a');
|
||||
expect(clean).toContain('href');
|
||||
expect(clean).toContain('example.com');
|
||||
});
|
||||
|
||||
it('should remove script tags', () => {
|
||||
const malicious = '<p>Signature</p><script>alert("XSS")</script>';
|
||||
const clean = sanitizeSignatureHtml(malicious);
|
||||
expect(clean).not.toContain('<script>');
|
||||
expect(clean).toContain('Signature');
|
||||
});
|
||||
|
||||
it('should handle empty signatures', () => {
|
||||
expect(sanitizeSignatureHtml('')).toBe('');
|
||||
expect(sanitizeSignatureHtml(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('should be stricter than email sanitization', () => {
|
||||
const html = '<p>Text</p><img src="pic.jpg"><table><tr><td>Data</td></tr></table>';
|
||||
const emailClean = sanitizeEmailHtml(html);
|
||||
const signatureClean = sanitizeSignatureHtml(html);
|
||||
|
||||
// Email allows img and table
|
||||
expect(emailClean).toContain('<img');
|
||||
expect(emailClean).toContain('<table>');
|
||||
|
||||
// Signature blocks img but may allow some tables (verify in implementation)
|
||||
expect(signatureClean).not.toContain('<img');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseHtmlSafely', () => {
|
||||
it('should return a valid Document', () => {
|
||||
const html = '<p>Test</p>';
|
||||
const doc = parseHtmlSafely(html);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
});
|
||||
|
||||
it('should not execute scripts', () => {
|
||||
let executed = false;
|
||||
const html = '<script>executed = true;</script>';
|
||||
parseHtmlSafely(html);
|
||||
expect(executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle malformed HTML gracefully', () => {
|
||||
const malformed = '<p>Unclosed<div>Tags';
|
||||
const doc = parseHtmlSafely(malformed);
|
||||
expect(doc).toBeInstanceOf(Document);
|
||||
expect(doc.body.textContent).toContain('Unclosed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasRichFormatting', () => {
|
||||
it('should detect tables', () => {
|
||||
const html = '<table><tr><td>Data</td></tr></table>';
|
||||
expect(hasRichFormatting(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect images', () => {
|
||||
const html = '<img src="pic.jpg">';
|
||||
expect(hasRichFormatting(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect inline styles', () => {
|
||||
const html = '<div style="color: red;">Styled</div>';
|
||||
expect(hasRichFormatting(html)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect formatting tags', () => {
|
||||
expect(hasRichFormatting('<b>Bold</b>')).toBe(true);
|
||||
expect(hasRichFormatting('<strong>Strong</strong>')).toBe(true);
|
||||
expect(hasRichFormatting('<em>Emphasized</em>')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect headings', () => {
|
||||
expect(hasRichFormatting('<h1>Title</h1>')).toBe(true);
|
||||
expect(hasRichFormatting('<h3>Subtitle</h3>')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect lists', () => {
|
||||
expect(hasRichFormatting('<ul><li>Item</li></ul>')).toBe(true);
|
||||
expect(hasRichFormatting('<ol><li>Item</li></ol>')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for plain text', () => {
|
||||
const plain = '<p>Just plain text</p>';
|
||||
expect(hasRichFormatting(plain)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for simple paragraphs', () => {
|
||||
const simple = '<p>Line 1</p><p>Line 2</p>';
|
||||
expect(hasRichFormatting(simple)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty HTML', () => {
|
||||
expect(hasRichFormatting('')).toBe(false);
|
||||
expect(hasRichFormatting(' ')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isValidEmail,
|
||||
validateEmailList,
|
||||
getEmailValidationError,
|
||||
isValidUnsubscribeUrl,
|
||||
parseUnsubscribeUrls,
|
||||
} from '../validation';
|
||||
|
||||
describe('validation', () => {
|
||||
describe('isValidEmail', () => {
|
||||
it('should accept valid basic emails', () => {
|
||||
expect(isValidEmail('user@example.com')).toBe(true);
|
||||
expect(isValidEmail('john.doe@company.co.uk')).toBe(true);
|
||||
expect(isValidEmail('test_user@subdomain.example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept emails with plus addressing', () => {
|
||||
expect(isValidEmail('user+tag@example.com')).toBe(true);
|
||||
expect(isValidEmail('user+shopping@example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept various valid formats', () => {
|
||||
expect(isValidEmail('a@b.co')).toBe(true);
|
||||
expect(isValidEmail('user123@test-domain.com')).toBe(true);
|
||||
expect(isValidEmail('first.last+tag@example.co.uk')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject emails without @ symbol', () => {
|
||||
expect(isValidEmail('userexample.com')).toBe(false);
|
||||
expect(isValidEmail('user')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject emails without domain', () => {
|
||||
expect(isValidEmail('user@')).toBe(false);
|
||||
expect(isValidEmail('@example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject header injection attempts', () => {
|
||||
expect(isValidEmail('test\r\nBcc:evil@example.com')).toBe(false);
|
||||
expect(isValidEmail('test\rBcc:evil@example.com')).toBe(false);
|
||||
expect(isValidEmail('test\nBcc:evil@example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject emails with dangerous characters', () => {
|
||||
expect(isValidEmail('test<script>@example.com')).toBe(false);
|
||||
expect(isValidEmail('test>evil@example.com')).toBe(false);
|
||||
expect(isValidEmail('test@evil>.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject overly long emails', () => {
|
||||
const longLocal = 'a'.repeat(256);
|
||||
expect(isValidEmail(`${longLocal}@example.com`)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject emails with local part > 64 chars', () => {
|
||||
const longLocal = 'a'.repeat(65);
|
||||
expect(isValidEmail(`${longLocal}@example.com`)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject emails with domain > 255 chars', () => {
|
||||
const longDomain = 'a'.repeat(256) + '.com';
|
||||
expect(isValidEmail(`user@${longDomain}`)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject domains starting or ending with dot', () => {
|
||||
expect(isValidEmail('user@.example.com')).toBe(false);
|
||||
expect(isValidEmail('user@example.com.')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject domains with consecutive dots', () => {
|
||||
expect(isValidEmail('user@example..com')).toBe(false);
|
||||
expect(isValidEmail('user@sub..domain.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty or null input', () => {
|
||||
expect(isValidEmail('')).toBe(false);
|
||||
expect(isValidEmail(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(isValidEmail('user@localhost')).toBe(true); // Valid per RFC
|
||||
expect(isValidEmail('user@192.168.1.1')).toBe(true); // IP address domain
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEmailList', () => {
|
||||
it('should validate single valid email', () => {
|
||||
const result = validateEmailList('user@example.com');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.invalidEmails).toEqual([]);
|
||||
});
|
||||
|
||||
it('should validate multiple valid emails', () => {
|
||||
const result = validateEmailList('user1@example.com, user2@test.com, user3@domain.co.uk');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.invalidEmails).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle whitespace around emails', () => {
|
||||
const result = validateEmailList(' user1@example.com , user2@test.com ');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.invalidEmails).toEqual([]);
|
||||
});
|
||||
|
||||
it('should reject list with one invalid email', () => {
|
||||
const result = validateEmailList('user1@example.com, invalid-email, user3@domain.com');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.invalidEmails).toEqual(['invalid-email']);
|
||||
});
|
||||
|
||||
it('should identify all invalid emails', () => {
|
||||
const result = validateEmailList('user1@example.com, bad1, user2@test.com, bad2@');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.invalidEmails).toContain('bad1');
|
||||
expect(result.invalidEmails).toContain('bad2@');
|
||||
expect(result.invalidEmails).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = validateEmailList('');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.invalidEmails).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle whitespace-only string', () => {
|
||||
const result = validateEmailList(' ');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.invalidEmails).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter out empty entries from commas', () => {
|
||||
const result = validateEmailList('user1@example.com,,user2@test.com,');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.invalidEmails).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailValidationError', () => {
|
||||
it('should return null for valid email', () => {
|
||||
expect(getEmailValidationError('user@example.com')).toBeNull();
|
||||
expect(getEmailValidationError('user+tag@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for empty email', () => {
|
||||
const error = getEmailValidationError('');
|
||||
expect(error).not.toBeNull();
|
||||
expect(error).toContain('required');
|
||||
});
|
||||
|
||||
it('should return error for whitespace-only email', () => {
|
||||
const error = getEmailValidationError(' ');
|
||||
expect(error).not.toBeNull();
|
||||
expect(error).toContain('required');
|
||||
});
|
||||
|
||||
it('should return error for overly long email', () => {
|
||||
const longEmail = 'a'.repeat(256) + '@example.com';
|
||||
const error = getEmailValidationError(longEmail);
|
||||
expect(error).not.toBeNull();
|
||||
expect(error).toContain('too long');
|
||||
expect(error).toContain('254');
|
||||
});
|
||||
|
||||
it('should return error for dangerous characters', () => {
|
||||
const error = getEmailValidationError('test\r\nBcc:evil@example.com');
|
||||
expect(error).not.toBeNull();
|
||||
expect(error).toContain('invalid characters');
|
||||
});
|
||||
|
||||
it('should return error for invalid format', () => {
|
||||
const error = getEmailValidationError('not-an-email');
|
||||
expect(error).not.toBeNull();
|
||||
expect(error).toContain('valid email');
|
||||
});
|
||||
|
||||
it('should provide user-friendly messages', () => {
|
||||
const error1 = getEmailValidationError('test@');
|
||||
const error2 = getEmailValidationError('@example.com');
|
||||
const error3 = getEmailValidationError('no-at-sign');
|
||||
|
||||
expect(error1).toContain('valid');
|
||||
expect(error2).toContain('valid');
|
||||
expect(error3).toContain('valid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidUnsubscribeUrl', () => {
|
||||
describe('HTTP/HTTPS URLs', () => {
|
||||
it('should accept valid HTTP URLs', () => {
|
||||
expect(isValidUnsubscribeUrl('http://example.com/unsubscribe')).toBe(true);
|
||||
expect(isValidUnsubscribeUrl('http://newsletter.example.com/unsub?id=123')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept valid HTTPS URLs', () => {
|
||||
expect(isValidUnsubscribeUrl('https://example.com/unsubscribe')).toBe(true);
|
||||
expect(isValidUnsubscribeUrl('https://example.com/unsub?token=abc123')).toBe(true);
|
||||
expect(isValidUnsubscribeUrl('https://sub.domain.com/unsubscribe')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept URLs with paths and query params', () => {
|
||||
expect(isValidUnsubscribeUrl('https://example.com/path/to/unsub?id=123&token=abc')).toBe(true);
|
||||
expect(isValidUnsubscribeUrl('http://example.com/unsub#section')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailto URLs', () => {
|
||||
it('should accept valid mailto URLs', () => {
|
||||
expect(isValidUnsubscribeUrl('mailto:unsubscribe@example.com')).toBe(true);
|
||||
expect(isValidUnsubscribeUrl('mailto:unsub@newsletter.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept mailto with query params', () => {
|
||||
expect(isValidUnsubscribeUrl('mailto:unsub@example.com?subject=Unsubscribe')).toBe(true);
|
||||
expect(isValidUnsubscribeUrl('mailto:unsub@example.com?subject=Remove&body=Please%20remove')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject mailto with invalid email', () => {
|
||||
expect(isValidUnsubscribeUrl('mailto:invalid-email')).toBe(false);
|
||||
expect(isValidUnsubscribeUrl('mailto:@example.com')).toBe(false);
|
||||
expect(isValidUnsubscribeUrl('mailto:user@')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('XSS attack vectors', () => {
|
||||
it('should reject javascript: protocol', () => {
|
||||
expect(isValidUnsubscribeUrl('javascript:alert(1)')).toBe(false);
|
||||
expect(isValidUnsubscribeUrl('javascript:alert(document.cookie)')).toBe(false);
|
||||
expect(isValidUnsubscribeUrl('javascript:void(0)')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject data: protocol', () => {
|
||||
expect(isValidUnsubscribeUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
|
||||
expect(isValidUnsubscribeUrl('data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject file: protocol', () => {
|
||||
expect(isValidUnsubscribeUrl('file:///etc/passwd')).toBe(false);
|
||||
expect(isValidUnsubscribeUrl('file://C:/Windows/System32/config')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject vbscript: protocol', () => {
|
||||
expect(isValidUnsubscribeUrl('vbscript:msgbox(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject about: protocol', () => {
|
||||
expect(isValidUnsubscribeUrl('about:blank')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject ftp: protocol', () => {
|
||||
expect(isValidUnsubscribeUrl('ftp://example.com/file')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should reject empty or null input', () => {
|
||||
expect(isValidUnsubscribeUrl('')).toBe(false);
|
||||
expect(isValidUnsubscribeUrl(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject malformed URLs', () => {
|
||||
expect(isValidUnsubscribeUrl('not-a-url')).toBe(false);
|
||||
expect(isValidUnsubscribeUrl('example.com/unsub')).toBe(false);
|
||||
expect(isValidUnsubscribeUrl('//example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject relative URLs', () => {
|
||||
expect(isValidUnsubscribeUrl('/unsubscribe')).toBe(false);
|
||||
expect(isValidUnsubscribeUrl('../unsub')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseUnsubscribeUrls', () => {
|
||||
it('should parse single HTTP URL', () => {
|
||||
const result = parseUnsubscribeUrls('<https://example.com/unsub>');
|
||||
expect(result.http).toBe('https://example.com/unsub');
|
||||
expect(result.mailto).toBeUndefined();
|
||||
expect(result.preferred).toBe('http');
|
||||
});
|
||||
|
||||
it('should parse single mailto URL', () => {
|
||||
const result = parseUnsubscribeUrls('<mailto:unsub@example.com>');
|
||||
expect(result.http).toBeUndefined();
|
||||
expect(result.mailto).toBe('mailto:unsub@example.com');
|
||||
expect(result.preferred).toBe('mailto');
|
||||
});
|
||||
|
||||
it('should parse multiple URLs and prefer HTTP', () => {
|
||||
const result = parseUnsubscribeUrls('<https://example.com/unsub>, <mailto:unsub@example.com>');
|
||||
expect(result.http).toBe('https://example.com/unsub');
|
||||
expect(result.mailto).toBe('mailto:unsub@example.com');
|
||||
expect(result.preferred).toBe('http');
|
||||
});
|
||||
|
||||
it('should prefer HTTP over mailto when both present', () => {
|
||||
const result = parseUnsubscribeUrls('<mailto:unsub@example.com>, <https://example.com/unsub>');
|
||||
expect(result.http).toBe('https://example.com/unsub');
|
||||
expect(result.mailto).toBe('mailto:unsub@example.com');
|
||||
expect(result.preferred).toBe('http');
|
||||
});
|
||||
|
||||
it('should handle URLs with query parameters', () => {
|
||||
const result = parseUnsubscribeUrls('<https://example.com/unsub?token=abc123&id=456>');
|
||||
expect(result.http).toBe('https://example.com/unsub?token=abc123&id=456');
|
||||
expect(result.preferred).toBe('http');
|
||||
});
|
||||
|
||||
it('should handle mailto with query parameters', () => {
|
||||
const result = parseUnsubscribeUrls('<mailto:unsub@example.com?subject=Unsubscribe&body=Remove>');
|
||||
expect(result.mailto).toBe('mailto:unsub@example.com?subject=Unsubscribe&body=Remove');
|
||||
expect(result.preferred).toBe('mailto');
|
||||
});
|
||||
|
||||
it('should filter out invalid URLs', () => {
|
||||
const result = parseUnsubscribeUrls('<javascript:alert(1)>, <https://example.com/unsub>');
|
||||
expect(result.http).toBe('https://example.com/unsub');
|
||||
expect(result.preferred).toBe('http');
|
||||
});
|
||||
|
||||
it('should return empty object for all invalid URLs', () => {
|
||||
const result = parseUnsubscribeUrls('<javascript:alert(1)>, <data:text/html,<script>>');
|
||||
expect(result.http).toBeUndefined();
|
||||
expect(result.mailto).toBeUndefined();
|
||||
expect(result.preferred).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty or null input', () => {
|
||||
expect(parseUnsubscribeUrls('')).toEqual({});
|
||||
expect(parseUnsubscribeUrls(' ')).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle malformed headers without angle brackets', () => {
|
||||
const result = parseUnsubscribeUrls('https://example.com/unsub');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle whitespace in headers', () => {
|
||||
const result = parseUnsubscribeUrls(' <https://example.com/unsub> , <mailto:unsub@example.com> ');
|
||||
expect(result.http).toBe('https://example.com/unsub');
|
||||
expect(result.mailto).toBe('mailto:unsub@example.com');
|
||||
expect(result.preferred).toBe('http');
|
||||
});
|
||||
|
||||
it('should handle three or more URLs', () => {
|
||||
const result = parseUnsubscribeUrls(
|
||||
'<https://example.com/unsub>, <http://backup.com/unsub>, <mailto:unsub@example.com>'
|
||||
);
|
||||
expect(result.http).toBeDefined();
|
||||
expect(result.mailto).toBe('mailto:unsub@example.com');
|
||||
expect(result.preferred).toBe('http');
|
||||
});
|
||||
|
||||
it('should validate email addresses in mailto URLs', () => {
|
||||
const result = parseUnsubscribeUrls('<mailto:invalid-email>, <https://example.com/unsub>');
|
||||
expect(result.mailto).toBeUndefined();
|
||||
expect(result.http).toBe('https://example.com/unsub');
|
||||
expect(result.preferred).toBe('http');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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('; ');
|
||||
}
|
||||
+11
-4
@@ -1,4 +1,5 @@
|
||||
import { AuthenticationResults } from './jmap/types';
|
||||
import { parseUnsubscribeUrls } from './validation';
|
||||
|
||||
/**
|
||||
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results
|
||||
@@ -191,7 +192,11 @@ export function parseSpamLLM(header: string): { verdict: string; explanation: st
|
||||
*/
|
||||
interface ListHeaders {
|
||||
listId?: string;
|
||||
listUnsubscribe?: string;
|
||||
listUnsubscribe?: {
|
||||
http?: string;
|
||||
mailto?: string;
|
||||
preferred?: 'http' | 'mailto';
|
||||
};
|
||||
listHelp?: string;
|
||||
listPost?: string;
|
||||
}
|
||||
@@ -209,9 +214,11 @@ export function extractListHeaders(headers: Record<string, string | string[]>):
|
||||
const unsub = Array.isArray(headers['List-Unsubscribe'])
|
||||
? headers['List-Unsubscribe'][0]
|
||||
: headers['List-Unsubscribe'];
|
||||
// Extract URL from <url> format
|
||||
const match = unsub.match(/<([^>]+)>/);
|
||||
result.listUnsubscribe = match ? match[1] : unsub;
|
||||
|
||||
const parsed = parseUnsubscribeUrls(unsub);
|
||||
if (parsed.preferred) {
|
||||
result.listUnsubscribe = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (headers['List-Help']) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Unified DOMPurify configuration for email content
|
||||
* Blocks all script execution vectors while preserving formatting
|
||||
* NOTE: <style> tags are forbidden to prevent global CSS injection
|
||||
* Inline style attributes are still allowed for element-specific styling
|
||||
*/
|
||||
export const EMAIL_SANITIZE_CONFIG = {
|
||||
ADD_TAGS: [],
|
||||
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
FORCE_BODY: true,
|
||||
FORBID_TAGS: [
|
||||
'script', 'iframe', 'object', 'embed', 'form',
|
||||
'input', 'button', 'meta', 'link', 'base',
|
||||
'svg', 'math', 'style'
|
||||
],
|
||||
FORBID_ATTR: [
|
||||
'onerror', 'onload', 'onclick', 'onmouseover',
|
||||
'onfocus', 'onblur', 'onchange', 'onsubmit',
|
||||
'onkeydown', 'onkeyup', 'onmousedown', 'onmouseup'
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize email HTML content
|
||||
* @param html - Raw HTML content from email
|
||||
* @returns Sanitized HTML safe for rendering
|
||||
*/
|
||||
export function sanitizeEmailHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize HTML signature with stricter rules
|
||||
* Only allows basic formatting, no external resources
|
||||
*/
|
||||
export const SIGNATURE_SANITIZE_CONFIG = {
|
||||
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div'],
|
||||
ALLOWED_ATTR: ['href', 'style', 'class'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'img', 'video', 'audio'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize HTML signature for storage and display
|
||||
* @param html - User-provided HTML signature
|
||||
* @returns Sanitized signature (no scripts, no external resources)
|
||||
*/
|
||||
export function sanitizeSignatureHtml(html: string): string {
|
||||
if (!html?.trim()) return '';
|
||||
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe HTML parsing without execution
|
||||
* Use instead of innerHTML for detection/parsing
|
||||
*/
|
||||
export function parseHtmlSafely(html: string): Document {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(html, 'text/html');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if HTML content has rich formatting
|
||||
* Safe alternative to innerHTML parsing
|
||||
*/
|
||||
export function hasRichFormatting(html: string): boolean {
|
||||
const doc = parseHtmlSafely(html);
|
||||
return !!doc.querySelector(
|
||||
'table, img, style, b, strong, i, em, u, font, ' +
|
||||
'div[style], span[style], p[style], ' +
|
||||
'h1, h2, h3, h4, h5, h6, ul, ol, blockquote'
|
||||
);
|
||||
}
|
||||
+182
-14
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress } from "./types";
|
||||
|
||||
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||
interface JMAPSession {
|
||||
@@ -753,6 +753,58 @@ export class JMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move email to Junk folder
|
||||
*/
|
||||
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
const mailboxes = await this.getMailboxes();
|
||||
const junkMailbox = mailboxes.find(m => {
|
||||
if (accountId) {
|
||||
return m.role === 'junk' && m.accountId === accountId;
|
||||
}
|
||||
return m.role === 'junk' && !m.isShared;
|
||||
});
|
||||
|
||||
if (!junkMailbox) {
|
||||
throw new Error('Junk mailbox not found');
|
||||
}
|
||||
|
||||
const mailboxId = accountId && junkMailbox.originalId
|
||||
? junkMailbox.originalId
|
||||
: junkMailbox.id;
|
||||
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: targetAccountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
mailboxIds: { [mailboxId]: true },
|
||||
},
|
||||
},
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo spam - move email back from Junk to original mailbox
|
||||
*/
|
||||
async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: targetAccountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
mailboxIds: { [originalMailboxId]: true },
|
||||
},
|
||||
},
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
|
||||
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||
try {
|
||||
// Use provided accountId or fallback to primary account
|
||||
@@ -920,15 +972,134 @@ export class JMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async createIdentity(
|
||||
name: string,
|
||||
email: string,
|
||||
replyTo?: EmailAddress[],
|
||||
bcc?: EmailAddress[],
|
||||
textSignature?: string,
|
||||
htmlSignature?: string
|
||||
): Promise<Identity> {
|
||||
const response = await this.request([
|
||||
["Identity/set", {
|
||||
accountId: this.accountId,
|
||||
create: {
|
||||
"new-identity": {
|
||||
name,
|
||||
email,
|
||||
replyTo,
|
||||
bcc,
|
||||
textSignature,
|
||||
htmlSignature,
|
||||
}
|
||||
}
|
||||
}, "0"]
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
// Check for errors
|
||||
if (result.notCreated?.["new-identity"]) {
|
||||
const error = result.notCreated["new-identity"];
|
||||
if (error.type === "forbidden") {
|
||||
throw new Error("You are not authorized to send from this email address");
|
||||
}
|
||||
throw new Error(error.description || "Failed to create identity");
|
||||
}
|
||||
|
||||
// Return created identity
|
||||
const createdId = result.created?.["new-identity"]?.id;
|
||||
if (createdId) {
|
||||
// Fetch the full identity object
|
||||
const identities = await this.getIdentities();
|
||||
const identity = identities.find(i => i.id === createdId);
|
||||
if (identity) return identity;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to create identity: Server response was unexpected. Check server logs.");
|
||||
}
|
||||
|
||||
async updateIdentity(
|
||||
identityId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
replyTo?: EmailAddress[];
|
||||
bcc?: EmailAddress[];
|
||||
textSignature?: string;
|
||||
htmlSignature?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const response = await this.request([
|
||||
["Identity/set", {
|
||||
accountId: this.accountId,
|
||||
update: {
|
||||
[identityId]: updates
|
||||
}
|
||||
}, "0"]
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
// Check for errors
|
||||
if (result.notUpdated?.[identityId]) {
|
||||
const error = result.notUpdated[identityId];
|
||||
if (error.type === "notFound") {
|
||||
throw new Error("Identity not found (may have been deleted)");
|
||||
}
|
||||
if (error.type === "forbidden") {
|
||||
throw new Error("You are not authorized to modify this identity");
|
||||
}
|
||||
throw new Error(error.description || "Failed to update identity");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Failed to update identity: Server response was unexpected. Check server logs.");
|
||||
}
|
||||
|
||||
async deleteIdentity(identityId: string): Promise<void> {
|
||||
const response = await this.request([
|
||||
["Identity/set", {
|
||||
accountId: this.accountId,
|
||||
destroy: [identityId]
|
||||
}, "0"]
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
// Check for errors
|
||||
if (result.notDestroyed?.[identityId]) {
|
||||
const error = result.notDestroyed[identityId];
|
||||
if (error.type === "forbidden") {
|
||||
throw new Error("This identity cannot be deleted");
|
||||
}
|
||||
if (error.type === "notFound") {
|
||||
throw new Error("Identity not found (may already be deleted)");
|
||||
}
|
||||
throw new Error(error.description || "Failed to delete identity");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Failed to delete identity: Server response was unexpected. Check server logs.");
|
||||
}
|
||||
|
||||
async createDraft(
|
||||
to: string[],
|
||||
subject: string,
|
||||
body: string,
|
||||
cc?: string[],
|
||||
bcc?: string[],
|
||||
identityId?: string,
|
||||
fromEmail?: string,
|
||||
draftId?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||
fromEmail?: string
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>
|
||||
): Promise<string> {
|
||||
// Find the drafts mailbox
|
||||
const mailboxes = await this.getMailboxes();
|
||||
@@ -1013,8 +1184,6 @@ export class JMAPClient {
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
|
||||
console.log('Draft save response:', JSON.stringify(response, null, 2));
|
||||
|
||||
// If we're updating (destroy + create), check the second response
|
||||
// Otherwise check the first response
|
||||
const responseIndex = draftId ? 1 : 0;
|
||||
@@ -1031,7 +1200,6 @@ export class JMAPClient {
|
||||
}
|
||||
|
||||
if (result.created?.[emailId]) {
|
||||
console.log('Draft created successfully:', result.created[emailId].id);
|
||||
return result.created[emailId].id;
|
||||
}
|
||||
}
|
||||
@@ -1046,9 +1214,9 @@ export class JMAPClient {
|
||||
body: string,
|
||||
cc?: string[],
|
||||
bcc?: string[],
|
||||
draftId?: string,
|
||||
identityId?: string,
|
||||
fromEmail?: string,
|
||||
selectedIdentityId?: string
|
||||
draftId?: string
|
||||
): Promise<void> {
|
||||
const emailId = draftId || `draft-${Date.now()}`;
|
||||
|
||||
@@ -1061,16 +1229,16 @@ export class JMAPClient {
|
||||
}
|
||||
|
||||
// Use provided identity ID or fetch from server as fallback
|
||||
let identityId = selectedIdentityId;
|
||||
let finalIdentityId = identityId;
|
||||
|
||||
if (!identityId) {
|
||||
if (!finalIdentityId) {
|
||||
const identityResponse = await this.request([
|
||||
["Identity/get", {
|
||||
accountId: this.accountId,
|
||||
}, "0"]
|
||||
]);
|
||||
|
||||
identityId = this.accountId; // fallback
|
||||
finalIdentityId = this.accountId; // fallback
|
||||
|
||||
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
||||
@@ -1078,7 +1246,7 @@ export class JMAPClient {
|
||||
if (identities.length > 0) {
|
||||
// Use the first identity (or find one matching the fromEmail/username)
|
||||
const matchingIdentity = identities.find((id) => id.email === (fromEmail || this.username));
|
||||
identityId = matchingIdentity?.id || identities[0].id;
|
||||
finalIdentityId = matchingIdentity?.id || identities[0].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1103,7 +1271,7 @@ export class JMAPClient {
|
||||
create: {
|
||||
"1": {
|
||||
emailId: draftId,
|
||||
identityId: identityId,
|
||||
identityId: finalIdentityId,
|
||||
},
|
||||
},
|
||||
}, "1"]);
|
||||
@@ -1137,7 +1305,7 @@ export class JMAPClient {
|
||||
create: {
|
||||
"1": {
|
||||
emailId: `#${emailId}`,
|
||||
identityId: identityId,
|
||||
identityId: finalIdentityId,
|
||||
},
|
||||
},
|
||||
}, "1"]);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Sub-addressing utilities for user+tag@domain.com format
|
||||
* Works server-side automatically - no JMAP API calls needed
|
||||
*/
|
||||
|
||||
// Constants for tag validation
|
||||
const MAX_TAG_LENGTH = 30;
|
||||
const TAG_REGEX = /^[a-zA-Z0-9-]{1,30}$/;
|
||||
|
||||
export type TagValidationErrorCode =
|
||||
| 'EMPTY'
|
||||
| 'TOO_LONG'
|
||||
| 'INVALID_CHARS'
|
||||
| null;
|
||||
|
||||
export interface ParsedAddress {
|
||||
localPart: string;
|
||||
baseUser: string;
|
||||
tag: string | null;
|
||||
domain: string;
|
||||
fullAddress: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an email address to extract sub-address tag
|
||||
* Example: "user+shopping@example.com" -> { baseUser: "user", tag: "shopping" }
|
||||
*/
|
||||
export function parseSubAddress(email: string): ParsedAddress {
|
||||
const [localPart, domain] = email.split('@');
|
||||
|
||||
if (!localPart || !domain) {
|
||||
return {
|
||||
localPart: localPart || '',
|
||||
baseUser: localPart || '',
|
||||
tag: null,
|
||||
domain: domain || '',
|
||||
fullAddress: email,
|
||||
};
|
||||
}
|
||||
|
||||
const plusIndex = localPart.indexOf('+');
|
||||
|
||||
if (plusIndex === -1) {
|
||||
return {
|
||||
localPart,
|
||||
baseUser: localPart,
|
||||
tag: null,
|
||||
domain,
|
||||
fullAddress: email,
|
||||
};
|
||||
}
|
||||
|
||||
const baseUser = localPart.substring(0, plusIndex);
|
||||
const tag = localPart.substring(plusIndex + 1);
|
||||
|
||||
return {
|
||||
localPart,
|
||||
baseUser,
|
||||
tag: tag || null,
|
||||
domain,
|
||||
fullAddress: email,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sub-addressed email
|
||||
* Example: generateSubAddress("user@example.com", "shopping") -> "user+shopping@example.com"
|
||||
*/
|
||||
export function generateSubAddress(baseEmail: string, tag: string): string {
|
||||
const [localPart, domain] = baseEmail.split('@');
|
||||
|
||||
if (!localPart || !domain || !tag) {
|
||||
return baseEmail;
|
||||
}
|
||||
|
||||
// Remove existing tag if present
|
||||
const cleanLocal = localPart.split('+')[0];
|
||||
|
||||
// Sanitize tag (alphanumeric and dash only)
|
||||
const cleanTag = tag.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
|
||||
|
||||
if (!cleanTag) {
|
||||
return baseEmail;
|
||||
}
|
||||
|
||||
return `${cleanLocal}+${cleanTag}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from recipient email for tag suggestions
|
||||
*/
|
||||
export function extractDomain(email: string): string | null {
|
||||
const match = email.match(/@([^@]+)$/);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest tags based on recipient domain
|
||||
*/
|
||||
export function suggestTagsForDomain(domain: string): string[] {
|
||||
const domainLower = domain.toLowerCase();
|
||||
|
||||
// Common domain-based suggestions
|
||||
const suggestions: Record<string, string[]> = {
|
||||
'amazon.com': ['amazon', 'shopping', 'orders'],
|
||||
'amazon.fr': ['amazon', 'shopping', 'orders'],
|
||||
'amazon.de': ['amazon', 'shopping', 'orders'],
|
||||
'amazon.co.uk': ['amazon', 'shopping', 'orders'],
|
||||
'ebay.com': ['ebay', 'shopping'],
|
||||
'ebay.fr': ['ebay', 'shopping'],
|
||||
'paypal.com': ['paypal', 'payments'],
|
||||
'facebook.com': ['facebook', 'social'],
|
||||
'twitter.com': ['twitter', 'social'],
|
||||
'x.com': ['twitter', 'social'],
|
||||
'linkedin.com': ['linkedin', 'professional'],
|
||||
'github.com': ['github', 'dev', 'notifications'],
|
||||
'gitlab.com': ['gitlab', 'dev', 'notifications'],
|
||||
'stackoverflow.com': ['stackoverflow', 'dev'],
|
||||
'reddit.com': ['reddit', 'social'],
|
||||
'netflix.com': ['netflix', 'entertainment'],
|
||||
'spotify.com': ['spotify', 'music'],
|
||||
'steam.com': ['steam', 'gaming'],
|
||||
'discord.com': ['discord', 'gaming'],
|
||||
};
|
||||
|
||||
// Check for exact domain match
|
||||
if (suggestions[domainLower]) {
|
||||
return suggestions[domainLower];
|
||||
}
|
||||
|
||||
// Extract main domain (e.g., "mail.google.com" -> "google")
|
||||
const parts = domainLower.split('.');
|
||||
const mainDomain = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
|
||||
|
||||
// Generic suggestions based on domain name
|
||||
return [mainDomain, 'newsletter', 'registration'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a tag is safe to use
|
||||
*/
|
||||
export function isValidTag(tag: string): boolean {
|
||||
return TAG_REGEX.test(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation error code for an invalid tag
|
||||
* Returns an error code that should be translated by the calling component
|
||||
*/
|
||||
export function getTagValidationError(tag: string): TagValidationErrorCode {
|
||||
if (!tag) {
|
||||
return 'EMPTY';
|
||||
}
|
||||
|
||||
if (tag.length > MAX_TAG_LENGTH) {
|
||||
return 'TOO_LONG';
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(tag)) {
|
||||
return 'INVALID_CHARS';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Export MAX_TAG_LENGTH for use in translations
|
||||
export { MAX_TAG_LENGTH };
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* RFC 5322 compliant email validation with security enhancements
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
// Length check
|
||||
if (!email || email.length > 254) return false;
|
||||
|
||||
// Security: Block control characters and header injection
|
||||
if (/[\r\n\0<>]/.test(email)) return false;
|
||||
|
||||
// RFC 5322 compliant regex (simplified but secure)
|
||||
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
|
||||
if (!emailRegex.test(email)) return false;
|
||||
|
||||
// Additional checks
|
||||
const [localPart, domain] = email.split('@');
|
||||
|
||||
// Local part max 64 chars
|
||||
if (localPart.length > 64) return false;
|
||||
|
||||
// Domain validation
|
||||
if (domain.length > 255) return false;
|
||||
if (domain.startsWith('.') || domain.endsWith('.')) return false;
|
||||
if (domain.includes('..')) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate comma-separated email list
|
||||
* @returns Object with validation result and invalid emails
|
||||
*/
|
||||
export function validateEmailList(csv: string): {
|
||||
valid: boolean;
|
||||
invalidEmails: string[];
|
||||
} {
|
||||
if (!csv?.trim()) {
|
||||
return { valid: true, invalidEmails: [] };
|
||||
}
|
||||
|
||||
const emails = csv.split(',').map(e => e.trim()).filter(Boolean);
|
||||
const invalid = emails.filter(e => !isValidEmail(e));
|
||||
|
||||
return {
|
||||
valid: invalid.length === 0,
|
||||
invalidEmails: invalid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly validation error message
|
||||
*/
|
||||
export function getEmailValidationError(email: string): string | null {
|
||||
if (!email?.trim()) return 'Email address is required';
|
||||
|
||||
if (email.length > 254) return 'Email address is too long (max 254 characters)';
|
||||
|
||||
if (/[\r\n\0<>]/.test(email)) {
|
||||
return 'Email address contains invalid characters';
|
||||
}
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate unsubscribe URL (RFC 2369 List-Unsubscribe)
|
||||
* Only allows safe protocols: http, https, mailto
|
||||
* @param url - URL to validate
|
||||
* @returns true if URL is safe to use
|
||||
*/
|
||||
export function isValidUnsubscribeUrl(url: string): boolean {
|
||||
if (!url?.trim()) return false;
|
||||
|
||||
if (url.startsWith('mailto:')) {
|
||||
const email = url.substring(7);
|
||||
const emailPart = email.split('?')[0];
|
||||
return isValidEmail(emailPart);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ['http:', 'https:'].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse List-Unsubscribe header and extract all valid URLs
|
||||
* RFC 2369 allows multiple comma-separated URLs in <url> format
|
||||
* @param header - Raw List-Unsubscribe header value
|
||||
* @returns Object with http and mailto URLs, plus preferred method
|
||||
*/
|
||||
export function parseUnsubscribeUrls(header: string): {
|
||||
http?: string;
|
||||
mailto?: string;
|
||||
preferred?: 'http' | 'mailto';
|
||||
} {
|
||||
if (!header?.trim()) return {};
|
||||
|
||||
const matches = header.match(/<([^>]+)>/g);
|
||||
if (!matches) return {};
|
||||
|
||||
const urls = matches.map(m => m.slice(1, -1).trim());
|
||||
|
||||
const http = urls.find(u =>
|
||||
(u.startsWith('http://') || u.startsWith('https://')) &&
|
||||
isValidUnsubscribeUrl(u)
|
||||
);
|
||||
const mailto = urls.find(u =>
|
||||
u.startsWith('mailto:') &&
|
||||
isValidUnsubscribeUrl(u)
|
||||
);
|
||||
|
||||
const preferred = http ? 'http' : (mailto ? 'mailto' : undefined);
|
||||
|
||||
return { http, mailto, preferred };
|
||||
}
|
||||
Reference in New Issue
Block a user