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
+78 -1
View File
@@ -75,6 +75,8 @@ export default function Home() {
setPushConnected,
handleStateChange,
clearNewEmailNotification,
markAsSpam,
undoSpam,
} = useEmailStore();
// Play notification sound for new emails
@@ -157,6 +159,18 @@ export default function Home() {
await markAsRead(client, selectedEmail.id, true);
}
},
onToggleSpam: () => {
if (selectedEmail) {
// Check if we're in junk folder
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInJunk = currentMailbox?.role === 'junk';
if (isInJunk) {
handleUndoSpam();
} else {
handleMarkAsSpam();
}
}
},
onCompose: () => {
setComposerMode('compose');
setShowComposer(true);
@@ -374,8 +388,11 @@ export default function Home() {
if (!client) return;
try {
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.draftId, data.fromEmail, data.identityId);
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId);
setShowComposer(false);
// Refresh the current mailbox to update the UI
await fetchEmails(client, selectedMailbox);
} catch (error) {
console.error("Failed to send email:", error);
}
@@ -442,6 +459,55 @@ export default function Home() {
}
};
const handleMarkAsSpam = async () => {
if (!client || !selectedEmail) return;
const emailId = selectedEmail.id;
try {
await markAsSpam(client, emailId);
const toastInstance = (await import('sonner')).toast;
toastInstance.success(t('email_viewer.spam.toast_success'), {
action: {
label: t('email_viewer.spam.toast_undo'),
onClick: async () => {
try {
await undoSpam(client, emailId);
toastInstance.success(t('notifications.email_moved'));
} catch (_error) {
console.error("Failed to undo spam:", _error);
toastInstance.error(t('email_viewer.spam.error'));
}
},
},
duration: 5000,
});
} catch (_error) {
console.error("Failed to mark as spam:", _error);
const toastInstance = (await import('sonner')).toast;
toastInstance.error(t('email_viewer.spam.error'));
}
};
const handleUndoSpam = async () => {
if (!client || !selectedEmail) return;
try {
await undoSpam(client, selectedEmail.id);
const toastInstance = (await import('sonner')).toast;
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
// Deselect email after moving it out of junk
selectEmail(null);
} catch (_error) {
console.error("Failed to restore email:", _error);
const toastInstance = (await import('sonner')).toast;
toastInstance.error(t('email_viewer.spam.error_not_spam'));
}
};
const handleSetColorTag = async (emailId: string, color: string | null) => {
if (!client) return;
@@ -766,6 +832,14 @@ export default function Home() {
await moveToMailbox(client, emailId, mailboxId);
}
}}
onMarkAsSpam={async (email) => {
selectEmail(email);
await handleMarkAsSpam();
}}
onUndoSpam={async (email) => {
selectEmail(email);
await handleUndoSpam();
}}
className="flex-1"
/>
</ErrorBoundary>
@@ -820,6 +894,8 @@ export default function Home() {
onArchive={handleArchive}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
onMarkAsSpam={handleMarkAsSpam}
onUndoSpam={handleUndoSpam}
onMarkAsRead={async (emailId, read) => {
if (client) {
await markAsRead(client, emailId, read);
@@ -833,6 +909,7 @@ export default function Home() {
}}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
className={isMobile ? "flex-1" : undefined}
/>
</ErrorBoundary>
+4 -1
View File
@@ -8,10 +8,11 @@ import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'advanced';
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'advanced';
export default function SettingsPage() {
const router = useRouter();
@@ -22,6 +23,7 @@ export default function SettingsPage() {
{ id: 'appearance', label: t('tabs.appearance') },
{ id: 'email', label: t('tabs.email') },
{ id: 'account', label: t('tabs.account') },
{ id: 'identities', label: t('tabs.identities') },
{ id: 'advanced', label: t('tabs.advanced') },
];
@@ -79,6 +81,7 @@ export default function SettingsPage() {
{activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />}
{activeTab === 'account' && <AccountSettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
</div>
</div>
+9 -2
View File
@@ -5,8 +5,15 @@ import { AlertTriangle, RefreshCw } from "lucide-react";
/**
* Global error boundary for the root layout.
* Note: This component cannot use translations since it's outside providers.
* It must render its own <html> and <body> tags as it replaces the root layout.
*
* IMPORTANT: Strings in this file CANNOT be translated.
* This global error boundary renders outside the root layout and has no access
* to providers (including next-intl). This is a Next.js limitation for
* catastrophic error handling. These English strings only appear during
* critical failures when the entire app crashes.
*
* The component must render its own <html> and <body> tags as it replaces
* the root layout entirely.
*/
export default function GlobalError({
error,
+2 -2
View File
@@ -139,7 +139,7 @@ body {
border-left: 3px solid #d1d5db;
padding-left: 1rem;
margin: 1rem 0;
color: #6b7280;
color: #4b5563;
font-style: italic;
}
@@ -281,7 +281,7 @@ body {
border-left: 3px solid #d1d5db;
padding-left: 1rem;
margin: 1rem 0;
color: #6b7280;
color: #4b5563;
opacity: 0.8;
}