mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-23 16:01:15 +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>
124 lines
3.2 KiB
TypeScript
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 };
|
|
}
|