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
+84 -25
View File
@@ -7,6 +7,8 @@ import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
interface EmailComposerProps {
onSend?: (data: {
@@ -42,6 +44,7 @@ export function EmailComposer({
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
// Initialize with reply/forward data if provided
const getInitialTo = () => {
@@ -64,9 +67,11 @@ export function EmailComposer({
const getInitialSubject = () => {
if (!replyTo?.subject) return "";
if (mode === 'forward') {
return `Fwd: ${replyTo.subject.replace(/^(Fwd:\s*)+/i, '')}`;
const fwdPrefix = t('prefix.forward');
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `Re: ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
const rePrefix = t('prefix.reply');
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
}
return "";
};
@@ -76,7 +81,7 @@ export function EmailComposer({
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : "Unknown";
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
if (mode === 'forward') {
return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
@@ -100,6 +105,7 @@ export function EmailComposer({
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [subAddressTag, setSubAddressTag] = useState<string>('');
const { client, identities, primaryIdentity } = useAuthStore();
@@ -176,7 +182,7 @@ export function EmailComposer({
}));
// Create a hash of current data to compare with last saved
const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments });
const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments, identityId: selectedIdentityId, subAddressTag });
// Only save if data has changed
if (currentData === lastSavedDataRef.current) {
@@ -185,13 +191,27 @@ export function EmailComposer({
setSaveStatus('saving');
// Get the selected identity or primary identity
const currentIdentity = selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity;
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
: currentIdentity.email
: undefined;
try {
const savedDraftId = await client.createDraft(
toAddresses,
subject || "(No subject)",
subject || t('no_subject'),
body,
ccAddresses,
bccAddresses,
currentIdentity?.id,
fromEmail,
draftId || undefined,
uploadedAttachments
);
@@ -263,6 +283,13 @@ export function EmailComposer({
? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity;
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
: currentIdentity.email
: undefined;
onSend?.({
to: toAddresses,
cc: ccAddresses,
@@ -270,7 +297,7 @@ export function EmailComposer({
subject,
body,
draftId: finalDraftId || undefined,
fromEmail: currentIdentity?.email,
fromEmail,
identityId: currentIdentity?.id,
});
@@ -281,6 +308,7 @@ export function EmailComposer({
setSubject("");
setBody("");
setDraftId(null);
setSubAddressTag("");
}
};
@@ -341,25 +369,56 @@ export function EmailComposer({
{/* From field - show dropdown if multiple identities, otherwise display email */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">{t('from')}:</span>
{identities.length > 1 ? (
<select
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors"
>
{identities.map((identity) => (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
</option>
))}
</select>
) : (
<span className="text-sm text-foreground">
{primaryIdentity?.name
? `${primaryIdentity.name} <${primaryIdentity.email}>`
: primaryIdentity?.email || ''}
</span>
)}
<div className="flex-1 flex items-center gap-1">
{identities.length > 1 ? (
<select
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors"
>
{identities.map((identity) => (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
</option>
))}
</select>
) : (
<span className="text-sm text-foreground flex-1">
{subAddressTag ? (
<span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
</span>
) : (
<>
{primaryIdentity?.name
? `${primaryIdentity.name} <${primaryIdentity.email}>`
: primaryIdentity?.email || ''}
</>
)}
</span>
)}
<SubAddressHelper
baseEmail={
(selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)?.email
: primaryIdentity?.email) || ''
}
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
onSelectTag={setSubAddressTag}
/>
{subAddressTag && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSubAddressTag('')}
className="h-6 px-2 text-xs"
title={t('remove_sub_address')}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2">