mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-24 08:01:19 +00:00
Implement RFC 2369 List-Unsubscribe support with security validation (protocol whitelist, XSS prevention), two-step confirmation UI, and localStorage persistence. Add intelligent color transformation for dark mode to fix unreadable inline email styles using WCAG 2.0 luminance calculation. Complete batch spam undo operations for context menu multi-select. Update documentation with new features and comprehensive test coverage (97 tests). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|