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
168 lines
4.2 KiB
TypeScript
168 lines
4.2 KiB
TypeScript
/**
|
|
* 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 };
|