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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user