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
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef } from 'react';
interface UseFocusTrapOptions {
isActive: boolean;
onEscape?: () => void;
restoreFocus?: boolean;
}
export function useFocusTrap({
isActive,
onEscape,
restoreFocus = true,
}: UseFocusTrapOptions) {
const containerRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isActive || !containerRef.current) return;
// Store the element that had focus before modal opened
previousActiveElement.current = document.activeElement as HTMLElement;
const container = containerRef.current;
// Get all focusable elements
const getFocusableElements = () => {
return container.querySelectorAll<HTMLElement>(
'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"]):not(:disabled)'
);
};
// Focus first element
const focusableElements = getFocusableElements();
const firstElement = focusableElements[0];
if (firstElement) {
firstElement.focus();
}
// Handle Tab key to trap focus
const handleKeyDown = (e: KeyboardEvent) => {
// Handle Escape
if (e.key === 'Escape' && onEscape) {
onEscape();
return;
}
// Handle Tab
if (e.key === 'Tab') {
const focusableElements = getFocusableElements();
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
// Shift+Tab
if (document.activeElement === firstElement) {
lastElement?.focus();
e.preventDefault();
}
} else {
// Tab
if (document.activeElement === lastElement) {
firstElement?.focus();
e.preventDefault();
}
}
}
};
container.addEventListener('keydown', handleKeyDown);
// Cleanup
return () => {
container.removeEventListener('keydown', handleKeyDown);
// Restore focus to previous element
if (restoreFocus && previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}, [isActive, onEscape, restoreFocus]);
return containerRef;
}
+9
View File
@@ -19,6 +19,7 @@ export interface KeyboardShortcutHandlers {
onDelete?: () => void;
onMarkAsUnread?: () => void;
onMarkAsRead?: () => void;
onToggleSpam?: () => void;
// Global actions
onCompose?: () => void;
@@ -180,6 +181,13 @@ export function useKeyboardShortcuts({
}
break;
case "!":
if (selectedEmailId) {
event.preventDefault();
h.onToggleSpam?.();
}
break;
// Global actions
case "c":
event.preventDefault();
@@ -264,6 +272,7 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "# / Del", description: "shortcuts.actions.delete" },
{ key: "u", description: "shortcuts.actions.mark_unread" },
{ key: "Shift + I", description: "shortcuts.actions.mark_read" },
{ key: "!", description: "shortcuts.actions.toggle_spam" },
],
global: [
{ key: "c", description: "shortcuts.global.compose" },
+22 -7
View File
@@ -10,6 +10,9 @@ import { toast } from "@/stores/toast-store";
interface UseMailboxDropOptions {
mailbox: Mailbox;
onDropComplete?: () => void;
// Translation callbacks for toast messages
onSuccess?: (count: number, mailboxName: string) => void;
onError?: (error: string) => void;
}
interface UseMailboxDropReturn {
@@ -24,7 +27,7 @@ interface UseMailboxDropReturn {
isInvalidDropTarget: boolean;
}
export function useMailboxDrop({ mailbox, onDropComplete }: UseMailboxDropOptions): UseMailboxDropReturn {
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore();
@@ -119,21 +122,33 @@ export function useMailboxDrop({ mailbox, onDropComplete }: UseMailboxDropOption
// Refresh the current mailbox view
await fetchEmails(client, selectedMailbox);
// Show success toast
if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailbox.name}`);
// Call success callback if provided, otherwise use fallback
if (onSuccess) {
onSuccess(emailIds.length, mailbox.name);
} else {
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
// Fallback for backward compatibility
if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailbox.name}`);
} else {
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
}
}
onDropComplete?.();
} catch (error) {
console.error("Failed to move emails:", error);
toast.error("Move failed", "Could not move emails to the selected folder");
// Call error callback if provided, otherwise use fallback
if (onError) {
onError(error instanceof Error ? error.message : 'Unknown error');
} else {
// Fallback for backward compatibility
toast.error("Move failed", "Could not move emails to the selected folder");
}
} finally {
endDrag();
}
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete]);
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget();