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:
+78
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user