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
+56 -15
View File
@@ -11,6 +11,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useTranslations } from "next-intl";
interface EmailListProps {
emails: Email[];
@@ -30,6 +31,8 @@ interface EmailListProps {
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
}
export function EmailList({
@@ -47,8 +50,11 @@ export function EmailList({
onDelete,
onArchive,
onSetColorTag,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
}: EmailListProps) {
const t = useTranslations('email_list');
const { client } = useAuthStore();
const {
selectedEmailIds,
@@ -57,6 +63,8 @@ export function EmailList({
batchMarkAsRead,
batchDelete,
batchMoveToMailbox,
batchMarkAsSpam,
batchUndoSpam,
loadMoreEmails,
hasMoreEmails,
isLoadingMore,
@@ -188,7 +196,7 @@ export function EmailList({
variant="ghost"
size="sm"
onClick={() => handleBatchMarkAsRead(true)}
title="Mark as read"
title={t('batch_actions.mark_read')}
disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50"
>
@@ -202,7 +210,7 @@ export function EmailList({
variant="ghost"
size="sm"
onClick={() => handleBatchMarkAsRead(false)}
title="Mark as unread"
title={t('batch_actions.mark_unread')}
disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50"
>
@@ -216,7 +224,7 @@ export function EmailList({
variant="ghost"
size="sm"
onClick={handleBatchDelete}
title="Delete"
title={t('batch_actions.delete')}
disabled={isProcessing}
className="text-red-600 dark:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50"
>
@@ -231,7 +239,7 @@ export function EmailList({
variant="ghost"
size="sm"
onClick={clearSelection}
title="Clear selection"
title={t('batch_actions.clear_selection')}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
@@ -261,13 +269,13 @@ export function EmailList({
)}
</button>
<h2 className="text-sm font-medium text-foreground">
{isLoading ? 'Loading...' : threadGroups.length > 0
? (totalEmails > threadGroups.length
? `${threadGroups.length} of ${totalEmails} conversations`
{isLoading ? t('loading') : threadGroups.length > 0
? (totalEmails !== undefined && totalEmails > threadGroups.length
? t('conversations_count', { count: threadGroups.length, total: totalEmails })
: hasMoreEmails
? `${threadGroups.length}+ conversations`
: `${threadGroups.length} conversations`)
: 'No conversations'}
? t('conversations_count_plus', { count: threadGroups.length })
: t('conversations_count_simple', { count: threadGroups.length }))
: t('no_conversations')}
</h2>
</div>
</div>
@@ -279,7 +287,7 @@ export function EmailList({
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-background/90 px-4 py-2 rounded-full shadow-sm border border-border">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Loading...</span>
<span>{t('loading')}</span>
</div>
</div>
)}
@@ -290,8 +298,8 @@ export function EmailList({
) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full py-12">
<Inbox className="w-16 h-16 mb-4 text-muted-foreground/50" />
<p className="text-base font-medium text-foreground">No emails in this mailbox</p>
<p className="text-sm mt-1 text-muted-foreground">New messages will appear here</p>
<p className="text-base font-medium text-foreground">{t('no_emails')}</p>
<p className="text-sm mt-1 text-muted-foreground">{t('no_emails_description')}</p>
</div>
) : (
<div className={cn("transition-opacity duration-200", isLoading && "opacity-50")}>
@@ -315,12 +323,12 @@ export function EmailList({
{isLoadingMore && hasMoreEmails && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Loading more emails...</span>
<span>{t('loading_more')}</span>
</div>
)}
{!hasMoreEmails && emails.length > 0 && (
<div className="text-sm text-muted-foreground border-t border-border pt-6">
No more emails to load
{t('no_more_emails')}
</div>
)}
</div>
@@ -338,6 +346,7 @@ export function EmailList({
menuRef={menuRef}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
selectedCount={selectedEmailIds.size}
// Single email actions
@@ -350,10 +359,42 @@ export function EmailList({
onArchive={() => onArchive?.(contextMenu.data!)}
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
// Batch actions
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
onBatchMarkAsSpam={async () => {
if (client) {
const emailIds = Array.from(selectedEmailIds);
try {
await batchMarkAsSpam(client, emailIds);
const { toast } = await import('sonner');
toast.success(
t('../email_viewer.spam.toast_batch', { count: emailIds.length })
);
} catch {
const { toast } = await import('sonner');
toast.error(t('../email_viewer.spam.error'));
}
}
}}
onBatchUndoSpam={async () => {
if (client) {
const emailIds = Array.from(selectedEmailIds);
try {
await batchUndoSpam(client, emailIds);
const { toast } = await import('sonner');
toast.success(
t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length })
);
} catch {
const { toast } = await import('sonner');
toast.error(t('../email_viewer.spam.error_not_spam'));
}
}
}}
/>
)}
</div>