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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user