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:
Matthieu MALVACHE
2026-01-08 22:15:32 +01:00
committed by Matthieu MALVACHE
parent 0d68851b63
commit 5d273e2109
49 changed files with 11018 additions and 432 deletions
+306
View File
@@ -0,0 +1,306 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
interface IdentityFormData {
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
}
interface IdentityFormProps {
identity?: Identity;
onSave: (data: IdentityFormData) => Promise<void>;
onCancel: () => void;
}
export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps) {
const t = useTranslations('identities.form');
const tValidation = useTranslations('identities.validation_errors');
const tDisplay = useTranslations('identities.display');
const isEditing = !!identity;
const [formData, setFormData] = useState<IdentityFormData>({
name: identity?.name || '',
email: identity?.email || '',
replyTo: identity?.replyTo,
bcc: identity?.bcc,
textSignature: identity?.textSignature || '',
htmlSignature: identity?.htmlSignature || '',
});
const [replyToInput, setReplyToInput] = useState(
identity?.replyTo?.map(a => a.email).join(', ') || ''
);
const [bccInput, setBccInput] = useState(
identity?.bcc?.map(a => a.email).join(', ') || ''
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const parseEmailList = (input: string): EmailAddress[] | undefined => {
if (!input.trim()) return undefined;
const emails = input.split(',').map(e => e.trim()).filter(Boolean);
return emails.map(email => ({ email }));
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = t('name_required');
}
// Use secure email validation
const emailError = getEmailValidationError(formData.email);
if (emailError) {
newErrors.email = emailError;
}
// Validate reply-to email list
if (replyToInput.trim()) {
const validation = validateEmailList(replyToInput);
if (!validation.valid) {
newErrors.replyTo = tValidation('invalid_emails', { emails: validation.invalidEmails.join(', ') });
}
}
// Validate bcc email list
if (bccInput.trim()) {
const validation = validateEmailList(bccInput);
if (!validation.valid) {
newErrors.bcc = tValidation('invalid_emails', { emails: validation.invalidEmails.join(', ') });
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
// Sanitize HTML signature before sending to server
const sanitizedData: IdentityFormData = {
...formData,
replyTo: parseEmailList(replyToInput),
bcc: parseEmailList(bccInput),
htmlSignature: formData.htmlSignature
? sanitizeSignatureHtml(formData.htmlSignature)
: undefined,
};
await onSave(sanitizedData);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
<label htmlFor="identity-name" className="block text-sm font-medium mb-1">
{t('name_label')} <span className="text-destructive">*</span>
</label>
<Input
id="identity-name"
type="text"
maxLength={256}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('name_placeholder')}
disabled={isSubmitting}
className={errors.name ? 'border-destructive' : ''}
aria-describedby={errors.name ? 'name-error' : undefined}
aria-invalid={!!errors.name}
/>
{errors.name && (
<p
id="name-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.name}
</p>
)}
</div>
{/* Email */}
<div>
<label htmlFor="identity-email" className="block text-sm font-medium mb-1">
{t('email_label')} <span className="text-destructive">*</span>
</label>
<Input
id="identity-email"
type="email"
maxLength={254}
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder={t('email_placeholder')}
disabled={isSubmitting || isEditing}
className={errors.email ? 'border-destructive' : ''}
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
/>
{isEditing && (
<p className="text-sm text-muted-foreground mt-1">
{t('email_immutable')}
</p>
)}
{errors.email && (
<p
id="email-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.email}
</p>
)}
</div>
{/* Reply-To */}
<div>
<label htmlFor="identity-reply-to" className="block text-sm font-medium mb-1">
{t('reply_to_label')}
</label>
<Input
id="identity-reply-to"
type="text"
maxLength={512}
value={replyToInput}
onChange={(e) => setReplyToInput(e.target.value)}
placeholder={t('reply_to_placeholder')}
disabled={isSubmitting}
className={errors.replyTo ? 'border-destructive' : ''}
aria-describedby={errors.replyTo ? 'reply-to-error' : undefined}
aria-invalid={!!errors.replyTo}
/>
{errors.replyTo && (
<p
id="reply-to-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.replyTo}
</p>
)}
</div>
{/* BCC */}
<div>
<label htmlFor="identity-bcc" className="block text-sm font-medium mb-1">
{t('bcc_label')}
</label>
<Input
id="identity-bcc"
type="text"
maxLength={512}
value={bccInput}
onChange={(e) => setBccInput(e.target.value)}
placeholder={t('bcc_placeholder')}
disabled={isSubmitting}
className={errors.bcc ? 'border-destructive' : ''}
aria-describedby={errors.bcc ? 'bcc-error' : undefined}
aria-invalid={!!errors.bcc}
/>
{errors.bcc && (
<p
id="bcc-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.bcc}
</p>
)}
</div>
{/* Text Signature */}
<div>
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
{t('text_signature_label')}
</label>
<textarea
id="identity-text-sig"
maxLength={2000}
value={formData.textSignature}
onChange={(e) => setFormData({ ...formData, textSignature: e.target.value })}
rows={3}
disabled={isSubmitting}
aria-label={t('text_signature_label')}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{/* HTML Signature */}
<div>
<label htmlFor="identity-html-sig" className="block text-sm font-medium mb-1">
{t('html_signature_label')}
</label>
<textarea
id="identity-html-sig"
maxLength={5000}
value={formData.htmlSignature}
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
rows={5}
disabled={isSubmitting}
aria-label={t('html_signature_label')}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
{formData.htmlSignature && (
<div className="mt-2 p-2 border rounded bg-muted">
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
<div
dangerouslySetInnerHTML={{
__html: sanitizeSignatureHtml(formData.htmlSignature)
}}
/>
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
{t('cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? isEditing
? t('updating')
: t('creating')
: t('save')}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,302 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { X, Mail, Pencil, Trash2, Plus, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
interface IdentityFormData {
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
}
interface IdentityManagerModalProps {
isOpen: boolean;
onClose: () => void;
}
export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalProps) {
const t = useTranslations('identities');
const tNotif = useTranslations('notifications');
const client = useAuthStore((state) => state.client);
const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Focus trap with Escape handling
const modalRef = useFocusTrap({
isActive: isOpen,
onEscape: () => {
if (isCreating || editingId) {
setIsCreating(false);
setEditingId(null);
} else {
onClose();
}
},
restoreFocus: true,
});
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, onClose, modalRef]);
const handleCreate = useCallback(async (data: IdentityFormData) => {
if (!client) return;
try {
const newIdentity = await client.createIdentity(
data.name,
data.email,
data.replyTo,
data.bcc,
data.textSignature,
data.htmlSignature
);
addIdentity(newIdentity);
setIsCreating(false);
toast.success(tNotif('identity_created'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_create_failed', { error: message }));
throw error;
}
}, [client, addIdentity, t, tNotif]);
const handleUpdate = useCallback(async (identity: Identity, data: IdentityFormData) => {
if (!client) return;
try {
await client.updateIdentity(identity.id, {
name: data.name,
replyTo: data.replyTo,
bcc: data.bcc,
textSignature: data.textSignature,
htmlSignature: data.htmlSignature,
});
updateIdentityLocal(identity.id, data);
setEditingId(null);
toast.success(tNotif('identity_updated'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_update_failed', { error: message }));
throw error;
}
}, [client, updateIdentityLocal, t, tNotif]);
const handleDelete = useCallback(async (identity: Identity) => {
if (!client) return;
if (!identity.mayDelete) {
toast.error(t('cannot_delete'));
return;
}
if (!window.confirm(t('delete_confirm'))) {
return;
}
setDeletingId(identity.id);
try {
await client.deleteIdentity(identity.id);
removeIdentity(identity.id);
toast.success(tNotif('identity_deleted'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_delete_failed', { error: message }));
} finally {
setDeletingId(null);
}
}, [client, removeIdentity, t, tNotif]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="identity-modal-title"
className={cn(
'bg-background border border-border rounded-lg shadow-xl',
'w-full max-w-3xl max-h-[90vh] overflow-hidden',
'animate-in zoom-in-95 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Mail className="w-5 h-5 text-muted-foreground" />
<h2 id="identity-modal-title" className="text-lg font-semibold text-foreground">
{t('modal_title')}
</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
{/* Create New Form */}
{isCreating && (
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
<h3 className="text-sm font-semibold mb-4">{t('create_new')}</h3>
<IdentityForm
onSave={handleCreate}
onCancel={() => setIsCreating(false)}
/>
</div>
)}
{/* Create Button */}
{!isCreating && !editingId && (
<Button
onClick={() => setIsCreating(true)}
className="mb-6 w-full sm:w-auto"
>
<Plus className="w-4 h-4 mr-2" />
{t('create_new')}
</Button>
)}
{/* Identities List */}
<div className="space-y-4">
{identities.map((identity) => (
<div
key={identity.id}
className="border border-border rounded-lg overflow-hidden"
>
{editingId === identity.id ? (
<div className="p-4 bg-muted/30">
<h3 className="text-sm font-semibold mb-4">
{t('edit_identity')}
</h3>
<IdentityForm
identity={identity}
onSave={(data) => handleUpdate(identity, data)}
onCancel={() => setEditingId(null)}
/>
</div>
) : (
<div className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-foreground truncate">
{identity.name}
</h3>
{identities[0]?.id === identity.id && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{t('primary_identity')}
</span>
)}
</div>
<p className="text-sm text-muted-foreground truncate">
{identity.email}
</p>
{/* Additional Info */}
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
{identity.replyTo && identity.replyTo.length > 0 && (
<p>
{t('display.reply_to')} {identity.replyTo.map((a) => a.email).join(', ')}
</p>
)}
{identity.bcc && identity.bcc.length > 0 && (
<p>
{t('display.bcc')} {identity.bcc.map((a) => a.email).join(', ')}
</p>
)}
{identity.textSignature && (
<p className="line-clamp-2">
{t('display.signature')} {identity.textSignature}
</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingId(identity.id)}
disabled={!!editingId || isCreating}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(identity)}
disabled={
!identity.mayDelete ||
!!editingId ||
isCreating ||
deletingId === identity.id
}
className={!identity.mayDelete ? 'opacity-30' : ''}
>
{!identity.mayDelete ? (
<AlertTriangle className="w-4 h-4 text-yellow-500" />
) : (
<Trash2 className="w-4 h-4 text-destructive" />
)}
</Button>
</div>
</div>
{!identity.mayDelete && (
<p className="text-xs text-yellow-600 dark:text-yellow-500 mt-2 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{t('cannot_delete')}
</p>
)}
</div>
)}
</div>
))}
{identities.length === 0 && !isCreating && (
<div className="text-center py-12 text-muted-foreground">
<Mail className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">{t('no_identities')}</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
'use client';
import { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Plus, Tag, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useIdentityStore } from '@/stores/identity-store';
import {
generateSubAddress,
extractDomain,
suggestTagsForDomain,
getTagValidationError,
MAX_TAG_LENGTH,
} from '@/lib/sub-addressing';
interface SubAddressHelperProps {
baseEmail: string;
recipientEmails: string[];
onSelectTag: (tag: string) => void;
disabled?: boolean;
}
export function SubAddressHelper({
baseEmail,
recipientEmails,
onSelectTag,
disabled = false,
}: SubAddressHelperProps) {
const t = useTranslations('identities.sub_address');
const [isOpen, setIsOpen] = useState(false);
const [tag, setTag] = useState('');
const [error, setError] = useState<string | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore();
// Get suggestions based on recipient (memoized for performance)
const suggestions = useMemo(() => {
return recipientEmails
.map(extractDomain)
.filter(Boolean)
.flatMap((domain) => suggestTagsForDomain(domain!))
.filter((tag, index, self) => self.indexOf(tag) === index)
.slice(0, 5);
}, [recipientEmails]);
// Generate preview
const preview = tag ? generateSubAddress(baseEmail, tag) : baseEmail;
// Close popover when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
const handleTagChange = (value: string) => {
setTag(value);
const errorCode = getTagValidationError(value);
// Translate error code to localized message
let errorMessage: string | null = null;
if (errorCode === 'EMPTY') {
errorMessage = t('validation.empty');
} else if (errorCode === 'TOO_LONG') {
errorMessage = t('validation.too_long', { max: MAX_TAG_LENGTH });
} else if (errorCode === 'INVALID_CHARS') {
errorMessage = t('validation.invalid_chars');
}
setError(errorMessage);
};
const handleSelectTag = (selectedTag: string) => {
const errorCode = getTagValidationError(selectedTag);
if (errorCode) {
// Translate error code to localized message
let errorMessage: string | null = null;
if (errorCode === 'EMPTY') {
errorMessage = t('validation.empty');
} else if (errorCode === 'TOO_LONG') {
errorMessage = t('validation.too_long', { max: MAX_TAG_LENGTH });
} else if (errorCode === 'INVALID_CHARS') {
errorMessage = t('validation.invalid_chars');
}
setError(errorMessage);
return;
}
// Add to recent tags and suggestions
addRecentTag(selectedTag);
const domain = recipientEmails.map(extractDomain).find(Boolean);
if (domain) {
addTagSuggestion(domain, selectedTag);
}
onSelectTag(selectedTag);
setIsOpen(false);
setTag('');
setError(null);
};
const handleUseAddress = () => {
if (!tag) return;
handleSelectTag(tag);
};
return (
<div className="relative">
{/* Trigger Button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsOpen(!isOpen)}
disabled={disabled}
title={t('button_tooltip')}
className="h-8 px-2"
>
<Plus className="w-4 h-4 mr-1" />
<Tag className="w-4 h-4" />
</Button>
{/* Popover */}
{isOpen && (
<div
ref={popoverRef}
className={cn(
'absolute top-full left-0 mt-1 z-50',
'bg-background border border-border rounded-lg shadow-lg',
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
)}
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground">
{t('popover_title')}
</h3>
<button
onClick={() => setIsOpen(false)}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tag Input */}
<div className="mb-3">
<Input
type="text"
value={tag}
onChange={(e) => handleTagChange(e.target.value)}
placeholder={t('tag_input_placeholder')}
className={cn(error && 'border-destructive')}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && tag && !error) {
e.preventDefault();
handleUseAddress();
}
}}
/>
{error && (
<p className="text-xs text-destructive mt-1">{error}</p>
)}
</div>
{/* Preview */}
<div className="mb-3 p-2 bg-muted rounded text-sm">
<div className="text-xs text-muted-foreground mb-1">
{t('preview_label')}
</div>
<div className="font-mono text-foreground break-all">
{preview}
</div>
</div>
{/* Recent Tags */}
{subAddress.recentTags.length > 0 && (
<div className="mb-3">
<div className="text-xs text-muted-foreground mb-2">
{t('recent_tags')}
</div>
<div className="flex flex-wrap gap-1">
{subAddress.recentTags.slice(0, 5).map((recentTag) => (
<button
key={recentTag}
onClick={() => handleSelectTag(recentTag)}
className="px-2 py-1 text-xs rounded bg-secondary hover:bg-accent text-foreground transition-colors"
>
{recentTag}
</button>
))}
</div>
</div>
)}
{/* Suggested Tags */}
{suggestions.length > 0 && (
<div className="mb-3">
<div className="text-xs text-muted-foreground mb-2">
{t('suggested_tags')}
</div>
<div className="flex flex-wrap gap-1">
{suggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => handleSelectTag(suggestion)}
className="px-2 py-1 text-xs rounded bg-primary/10 hover:bg-primary/20 text-primary transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
)}
{/* Help Text */}
<div className="mb-3 text-xs text-muted-foreground">
{t('help_text')}
</div>
{/* Use Address Button */}
<Button
onClick={handleUseAddress}
disabled={!tag || !!error}
className="w-full"
size="sm"
>
{t('use_address')}
</Button>
</div>
)}
</div>
);
}