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