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
307 lines
9.7 KiB
TypeScript
307 lines
9.7 KiB
TypeScript
'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>
|
|
);
|
|
}
|