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

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

124 lines
3.2 KiB
TypeScript

/**
* 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 };
}