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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user