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
+84 -25
View File
@@ -7,6 +7,8 @@ import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
interface EmailComposerProps {
onSend?: (data: {
@@ -42,6 +44,7 @@ export function EmailComposer({
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
// Initialize with reply/forward data if provided
const getInitialTo = () => {
@@ -64,9 +67,11 @@ export function EmailComposer({
const getInitialSubject = () => {
if (!replyTo?.subject) return "";
if (mode === 'forward') {
return `Fwd: ${replyTo.subject.replace(/^(Fwd:\s*)+/i, '')}`;
const fwdPrefix = t('prefix.forward');
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `Re: ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
const rePrefix = t('prefix.reply');
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
}
return "";
};
@@ -76,7 +81,7 @@ export function EmailComposer({
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : "Unknown";
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
if (mode === 'forward') {
return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
@@ -100,6 +105,7 @@ export function EmailComposer({
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [subAddressTag, setSubAddressTag] = useState<string>('');
const { client, identities, primaryIdentity } = useAuthStore();
@@ -176,7 +182,7 @@ export function EmailComposer({
}));
// Create a hash of current data to compare with last saved
const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments });
const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments, identityId: selectedIdentityId, subAddressTag });
// Only save if data has changed
if (currentData === lastSavedDataRef.current) {
@@ -185,13 +191,27 @@ export function EmailComposer({
setSaveStatus('saving');
// Get the selected identity or primary identity
const currentIdentity = selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity;
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
: currentIdentity.email
: undefined;
try {
const savedDraftId = await client.createDraft(
toAddresses,
subject || "(No subject)",
subject || t('no_subject'),
body,
ccAddresses,
bccAddresses,
currentIdentity?.id,
fromEmail,
draftId || undefined,
uploadedAttachments
);
@@ -263,6 +283,13 @@ export function EmailComposer({
? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity;
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
: currentIdentity.email
: undefined;
onSend?.({
to: toAddresses,
cc: ccAddresses,
@@ -270,7 +297,7 @@ export function EmailComposer({
subject,
body,
draftId: finalDraftId || undefined,
fromEmail: currentIdentity?.email,
fromEmail,
identityId: currentIdentity?.id,
});
@@ -281,6 +308,7 @@ export function EmailComposer({
setSubject("");
setBody("");
setDraftId(null);
setSubAddressTag("");
}
};
@@ -341,25 +369,56 @@ export function EmailComposer({
{/* From field - show dropdown if multiple identities, otherwise display email */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">{t('from')}:</span>
{identities.length > 1 ? (
<select
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors"
>
{identities.map((identity) => (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
</option>
))}
</select>
) : (
<span className="text-sm text-foreground">
{primaryIdentity?.name
? `${primaryIdentity.name} <${primaryIdentity.email}>`
: primaryIdentity?.email || ''}
</span>
)}
<div className="flex-1 flex items-center gap-1">
{identities.length > 1 ? (
<select
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors"
>
{identities.map((identity) => (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
</option>
))}
</select>
) : (
<span className="text-sm text-foreground flex-1">
{subAddressTag ? (
<span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
</span>
) : (
<>
{primaryIdentity?.name
? `${primaryIdentity.name} <${primaryIdentity.email}>`
: primaryIdentity?.email || ''}
</>
)}
</span>
)}
<SubAddressHelper
baseEmail={
(selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)?.email
: primaryIdentity?.email) || ''
}
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
onSelectTag={setSubAddressTag}
/>
{subAddressTag && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSubAddressTag('')}
className="h-6 px-2 text-xs"
title={t('remove_sub_address')}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2">
+42 -11
View File
@@ -25,6 +25,8 @@ import {
Send,
File,
Folder,
ShieldAlert,
ShieldCheck,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -41,6 +43,7 @@ interface EmailContextMenuProps {
menuRef: React.RefObject<HTMLDivElement | null>;
mailboxes: Mailbox[];
selectedMailbox: string;
currentMailboxRole?: string;
isMultiSelect?: boolean;
selectedCount?: number;
// Single email actions
@@ -53,23 +56,16 @@ interface EmailContextMenuProps {
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMoveToMailbox?: (mailboxId: string) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
// Batch actions
onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void;
onBatchMoveToMailbox?: (mailboxId: string) => void;
onBatchMarkAsSpam?: () => void;
onBatchUndoSpam?: () => void;
}
// Color options for email tags
const colorOptions = [
{ name: "Red", value: "red", color: "bg-red-500" },
{ name: "Orange", value: "orange", color: "bg-orange-500" },
{ name: "Yellow", value: "yellow", color: "bg-yellow-500" },
{ name: "Green", value: "green", color: "bg-green-500" },
{ name: "Blue", value: "blue", color: "bg-blue-500" },
{ name: "Purple", value: "purple", color: "bg-purple-500" },
{ name: "Pink", value: "pink", color: "bg-pink-500" },
];
// Get mailbox icon based on role
const getMailboxIcon = (role?: string) => {
switch (role) {
@@ -107,6 +103,7 @@ export function EmailContextMenu({
menuRef,
mailboxes,
selectedMailbox,
currentMailboxRole,
isMultiSelect = false,
selectedCount = 1,
onReply,
@@ -118,15 +115,32 @@ export function EmailContextMenu({
onArchive,
onSetColorTag,
onMoveToMailbox,
onMarkAsSpam,
onUndoSpam,
onBatchMarkAsRead,
onBatchDelete,
onBatchMoveToMailbox,
onBatchMarkAsSpam,
onBatchUndoSpam,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const tColor = useTranslations("email_viewer.color_tag");
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const currentColor = getCurrentColor(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Color options for email tags (using translations)
const colorOptions = [
{ name: tColor("red"), value: "red", color: "bg-red-500" },
{ name: tColor("orange"), value: "orange", color: "bg-orange-500" },
{ name: tColor("yellow"), value: "yellow", color: "bg-yellow-500" },
{ name: tColor("green"), value: "green", color: "bg-green-500" },
{ name: tColor("blue"), value: "blue", color: "bg-blue-500" },
{ name: tColor("purple"), value: "purple", color: "bg-purple-500" },
{ name: tColor("pink"), value: "pink", color: "bg-pink-500" },
];
// Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes)
const moveTargets = mailboxes.filter(
@@ -239,6 +253,23 @@ export function EmailContextMenu({
<ContextMenuSeparator />
{/* Spam - contextual based on folder */}
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
onClick={() =>
handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
<ContextMenuSeparator />
{/* Set color submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Palette} label={t("color_tag")}>
+142
View File
@@ -0,0 +1,142 @@
'use client';
import { useTranslations } from 'next-intl';
import { Mail, Tag } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Email, Identity } from '@/lib/jmap/types';
import { parseSubAddress } from '@/lib/sub-addressing';
interface EmailIdentityBadgeProps {
email: Email;
identities: Identity[];
compact?: boolean;
className?: string;
}
export function EmailIdentityBadge({
email,
identities,
compact = false,
className,
}: EmailIdentityBadgeProps) {
const t = useTranslations('identities.badge');
const fromAddress = email.from?.[0]?.email;
if (!fromAddress) return null;
// Parse the from address to check for sub-addressing
const parsedFrom = parseSubAddress(fromAddress);
// Find matching identity (email sent BY the user)
const matchingIdentity = identities.find(
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
);
// Check if email was sent TO a sub-address (received email)
let receivedToTag: string | null = null;
if (!matchingIdentity) {
// Check all TO addresses for sub-address tags matching user's identities
for (const recipient of email.to || []) {
const parsedTo = parseSubAddress(recipient.email);
if (parsedTo.tag) {
// Check if this base email matches any of the user's identities
const matchingToIdentity = identities.find(
(identity) => identity.email === `${parsedTo.baseUser}@${parsedTo.domain}`
);
if (matchingToIdentity) {
receivedToTag = parsedTo.tag;
break;
}
}
}
}
// Determine which tag to display (sent or received)
const displayTag = matchingIdentity ? parsedFrom.tag : receivedToTag;
// Don't show badge if not from user's identity and not to user's sub-address
if (!matchingIdentity && !receivedToTag) return null;
if (compact) {
// Compact view for email list
if (displayTag) {
return (
<div
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs',
'bg-primary/10 text-primary',
className
)}
title={t('sub_address_tag', { tag: displayTag })}
>
<Tag className="w-3 h-3" />
<span className="font-mono">+{displayTag}</span>
</div>
);
}
if (
matchingIdentity &&
matchingIdentity.name &&
matchingIdentity.name !== matchingIdentity.email &&
matchingIdentity.name !== fromAddress
) {
return (
<div
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs',
'bg-secondary text-muted-foreground',
className
)}
title={t('identity_name', { name: matchingIdentity.name })}
>
<Mail className="w-3 h-3" />
<span className="truncate max-w-[100px]">{matchingIdentity.name}</span>
</div>
);
}
return null;
}
// Full view for email viewer - now shows compact badges only
return (
<div className={cn('inline-flex items-center gap-2', className)}>
{/* Sub-address tag badge */}
{displayTag && (
<div
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md',
'bg-primary/10 text-primary border border-primary/20',
'text-xs font-semibold'
)}
title={t('sub_address_tag', { tag: displayTag })}
aria-label={t('sub_address_tag', { tag: displayTag })}
>
<Tag className="w-3 h-3" />
<span className="font-mono">{t('subaddress_tag', { tag: displayTag })}</span>
</div>
)}
{/* Identity badge (only if identity has a name and no sub-address tag) */}
{!displayTag &&
matchingIdentity &&
matchingIdentity.name &&
matchingIdentity.name !== matchingIdentity.email &&
matchingIdentity.name !== fromAddress && (
<div
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md',
'bg-secondary text-muted-foreground border border-border',
'text-xs font-medium'
)}
title={t('identity_name', { name: matchingIdentity.name })}
aria-label={t('identity_name', { name: matchingIdentity.name })}
>
<Mail className="w-3 h-3" />
<span>{t('identity_short', { name: matchingIdentity.name })}</span>
</div>
)}
</div>
);
}
+7 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
@@ -7,7 +8,9 @@ import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { EmailIdentityBadge } from "./email-identity-badge";
interface EmailListItemProps {
email: Email;
@@ -39,8 +42,10 @@ const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
};
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
@@ -145,6 +150,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
Important
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
@@ -167,7 +173,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
{email.subject || t('no_subject')}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
+56 -15
View File
@@ -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>
+239 -106
View File
@@ -3,10 +3,11 @@
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email } from "@/lib/jmap/types";
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn } from "@/lib/utils";
import { getSecurityStatus } from "@/lib/email-headers";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import {
Reply,
ReplyAll,
@@ -51,6 +52,11 @@ import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { transformInlineStyles } from "@/lib/color-transform";
import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
interface EmailViewerProps {
email: Email | null;
@@ -65,9 +71,12 @@ interface EmailViewerProps {
onSetColorTag?: (emailId: string, color: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onQuickReply?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onBack?: () => void;
currentUserEmail?: string;
currentUserName?: string;
currentMailboxRole?: string;
className?: string;
}
@@ -97,17 +106,6 @@ const getFileIcon = (name?: string, type?: string) => {
return File;
};
// Color options for email tags
const colorOptions = [
{ name: "Red", value: "red", color: "bg-red-500" },
{ name: "Orange", value: "orange", color: "bg-orange-500" },
{ name: "Yellow", value: "yellow", color: "bg-yellow-500" },
{ name: "Green", value: "green", color: "bg-green-500" },
{ name: "Blue", value: "blue", color: "bg-blue-500" },
{ name: "Purple", value: "purple", color: "bg-purple-500" },
{ name: "Pink", value: "pink", color: "bg-pink-500" },
];
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
@@ -118,6 +116,42 @@ const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
return null;
};
// Helper function to format recipients with contextual display
const formatRecipients = (
recipients: Array<{ name?: string; email: string }> | undefined,
currentUserEmail: string | undefined,
t: (key: string, params?: Record<string, string | number>) => string
): string => {
if (!recipients || recipients.length === 0) return '';
// Check if the first recipient is the current user
const firstRecipient = recipients[0];
const isFirstRecipientMe = currentUserEmail &&
(firstRecipient.email.toLowerCase() === currentUserEmail.toLowerCase() ||
firstRecipient.email.toLowerCase().startsWith(currentUserEmail.toLowerCase().split('@')[0] + '+'));
// If only one recipient and it's the current user, show "me"
if (recipients.length === 1 && isFirstRecipientMe) {
return t('recipient_me');
}
// Format up to 2 recipients by name (or email if no name)
const displayRecipients = recipients.slice(0, 2).map((r, index) => {
if (index === 0 && isFirstRecipientMe) {
return t('recipient_me');
}
return r.name || r.email;
});
// If more than 2 recipients, add count
if (recipients.length > 2) {
const displayName = displayRecipients[0];
return t('recipient_and_others', { name: displayName, count: recipients.length - 1 });
}
return displayRecipients.join(', ');
};
export function EmailViewer({
email,
isLoading = false,
@@ -131,9 +165,12 @@ export function EmailViewer({
onSetColorTag,
onDownloadAttachment,
onQuickReply,
onMarkAsSpam,
onUndoSpam,
onBack,
currentUserEmail,
currentUserName,
currentMailboxRole,
className,
}: EmailViewerProps) {
const t = useTranslations('email_viewer');
@@ -143,9 +180,25 @@ export function EmailViewer({
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
// Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk';
// Color options for email tags (using translations)
const colorOptions = [
{ name: t("color_tag.red"), value: "red", color: "bg-red-500" },
{ name: t("color_tag.orange"), value: "orange", color: "bg-orange-500" },
{ name: t("color_tag.yellow"), value: "yellow", color: "bg-yellow-500" },
{ name: t("color_tag.green"), value: "green", color: "bg-green-500" },
{ name: t("color_tag.blue"), value: "blue", color: "bg-blue-500" },
{ name: t("color_tag.purple"), value: "purple", color: "bg-purple-500" },
{ name: t("color_tag.pink"), value: "pink", color: "bg-pink-500" },
];
// Tablet list visibility
const { isTablet } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities } = useAuthStore();
const theme = useThemeStore((state) => state.theme);
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [allowExternalContent, setAllowExternalContent] = useState(false);
const [hasBlockedContent, setHasBlockedContent] = useState(false);
@@ -154,6 +207,13 @@ export function EmailViewer({
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
const [showSourceModal, setShowSourceModal] = useState(false);
const currentColor = getCurrentColor(email?.keywords);
const [dismissedUnsubBanners, setDismissedUnsubBanners] = useState<Set<string>>(
() => {
if (typeof window === 'undefined') return new Set();
const saved = localStorage.getItem('dismissed-unsub-banners');
return saved ? new Set(JSON.parse(saved)) : new Set();
}
);
useEffect(() => {
// Mark as read when email is viewed
@@ -339,16 +399,8 @@ export function EmailViewer({
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Check if HTML is just a minimal wrapper around plain text
// by checking if it lacks common HTML formatting elements
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote');
const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2;
const hasBrTags = tempDiv.querySelectorAll('br').length > 0;
// Use HTML if it has rich formatting, multiple paragraphs, or explicit line breaks
useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags);
// Use safe parsing instead of innerHTML to detect rich formatting
useHtmlVersion = hasRichFormatting(htmlContent);
}
// If we should use HTML version and it exists
@@ -356,14 +408,8 @@ export function EmailViewer({
// Create a custom DOMPurify hook to handle external content
let blockedExternalContent = false;
const sanitizeConfig = {
ADD_TAGS: ['style'],
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: true,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
};
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
// Check if sender is trusted
const senderEmail = email.from?.[0]?.email?.toLowerCase();
@@ -406,6 +452,15 @@ export function EmailViewer({
blockedExternalContent = true;
}
}
// Transform inline color styles for dark mode readability
if (theme === 'dark') {
const originalStyles = htmlNode.style.cssText;
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
if (transformedStyles !== originalStyles) {
htmlNode.style.cssText = transformedStyles;
}
}
}
});
}
@@ -460,16 +515,26 @@ export function EmailViewer({
.replace(/\n/g, '<br>');
return {
html: `<div style="color: #666; font-style: italic;">${previewHtml}</div>`,
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
isHtml: false
};
}
return {
html: '<p style="color: #999;">No content available</p>',
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false
};
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted]);
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, theme]);
// Detect List-Unsubscribe header for newsletter banners
const listHeaders = useMemo(() => {
if (!email?.headers) return null;
return extractListHeaders(email.headers);
}, [email?.headers]);
const shouldShowUnsubBanner =
listHeaders?.listUnsubscribe?.preferred &&
!dismissedUnsubBanners.has(email?.messageId || '');
// Show loading skeleton while email is being fetched
if (isLoading && !email) {
@@ -574,7 +639,7 @@ export function EmailViewer({
)}
<div className="flex-1 min-w-0">
<h1 className="text-lg lg:text-2xl font-bold text-foreground tracking-tight truncate pr-2">
{email.subject || "(no subject)"}
{email.subject || t('no_subject')}
</h1>
<div className="flex items-center gap-2 lg:gap-3 mt-1.5 lg:mt-2 text-xs lg:text-sm text-muted-foreground flex-wrap lg:flex-nowrap">
<span className="flex items-center gap-1 lg:gap-1.5 whitespace-nowrap">
@@ -616,7 +681,7 @@ export function EmailViewer({
onClick={onReply}
size="sm"
className="mr-1 h-8 lg:h-9"
title="Reply"
title={t('tooltips.reply')}
>
<Reply className="w-4 h-4" />
<span className="ml-1.5 hidden lg:inline">Reply</span>
@@ -657,16 +722,39 @@ export function EmailViewer({
size="icon"
onClick={onArchive}
className="h-8 w-8 hover:bg-muted hidden lg:flex"
title="Archive"
title={t('tooltips.archive')}
>
<Archive className="w-4 h-4 text-muted-foreground" />
</Button>
{/* Spam/Not Spam Button - Desktop only, contextual based on folder */}
{(onMarkAsSpam || onUndoSpam) && (
<Button
variant="ghost"
size="icon"
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
className={cn(
"hidden h-8 w-8 lg:flex",
isInJunkFolder
? "hover:bg-green-50 dark:hover:bg-green-950/30"
: "hover:bg-red-50 dark:hover:bg-red-950/30"
)}
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
>
{isInJunkFolder ? (
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={onDelete}
className="h-8 w-8 hover:bg-muted"
title="Delete"
title={t('tooltips.delete')}
>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
@@ -768,6 +856,30 @@ export function EmailViewer({
<Printer className="w-4 h-4" />
{t('print')}
</button>
{/* Separator */}
<div className="h-px bg-border my-1" />
{/* Spam action - contextual */}
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
className={cn(
"w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2",
isInJunkFolder ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"
)}
>
{isInJunkFolder ? (
<>
<ShieldCheck className="w-4 h-4" />
{t('spam.not_spam_title')}
</>
) : (
<>
<ShieldAlert className="w-4 h-4" />
{t('spam.button_title')}
</>
)}
</button>
)}
</div>
</div>
</div>
@@ -786,40 +898,39 @@ export function EmailViewer({
/>
<div className="flex-1 min-w-0">
{/* Sender line with compact badges */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground">
{sender?.name || sender?.email || t('unknown_sender')}
</span>
{sender?.email && sender?.name && (
<span className="text-sm text-muted-foreground">
&lt;{sender.email}&gt;
</span>
)}
<EmailIdentityBadge email={email} identities={identities} />
</div>
{/* Recipient section - separate line */}
<div className="mt-2 space-y-1">
{email.to && email.to.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">To:</span>
<span className="text-muted-foreground">{t('recipient_to_prefix')}</span>
<span className="text-foreground">
{email.to.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.to.length > 2 && (
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline"
>
{t('more_count', { count: email.to.length - 2 })}
</button>
)}
{formatRecipients(email.to, currentUserEmail, t)}
</span>
{email.to.length > 2 && (
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline"
>
{t('more_count', { count: email.to.length - 2 })}
</button>
)}
</div>
)}
{(email.cc && email.cc.length > 0) && (
{email.cc && email.cc.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">CC:</span>
<span className="text-foreground">
{email.cc.map(r => r.name || r.email).join(", ")}
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
</span>
</div>
)}
@@ -1139,72 +1250,94 @@ export function EmailViewer({
className="shadow-sm w-10 h-10"
/>
<div className="flex-1 min-w-0">
{/* Mobile 2-line layout */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground">
<span className="text-sm font-semibold text-foreground">
{sender?.name || sender?.email || t('unknown_sender')}
</span>
<EmailIdentityBadge email={email} identities={identities} />
</div>
<div className="mt-1 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
{sender?.email && sender?.name && (
<span className="text-sm text-muted-foreground">
&lt;{sender.email}&gt;
</span>
<>
<span className="truncate">{sender.email}</span>
<span>·</span>
</>
)}
</div>
<div className="mt-1 space-y-0.5">
{email.to && email.to.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">To:</span>
<span className="text-foreground truncate">
{email.to.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.to.length > 2 && ` +${email.to.length - 2}`}
<>
<span> {t('recipient_to_prefix')}</span>
<span className="text-foreground">
{formatRecipients(email.to, currentUserEmail, t)}
</span>
</div>
)}
{email.cc && email.cc.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">CC:</span>
<span className="text-foreground truncate">
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
</span>
</div>
</>
)}
</div>
{/* CC line (mobile - only if present) */}
{email.cc && email.cc.length > 0 && (
<div className="mt-1 flex items-center gap-1 text-sm">
<span className="text-muted-foreground">CC:</span>
<span className="text-foreground truncate">
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
</span>
</div>
)}
</div>
</div>
</div>
{/* External Content Banner - show in 'ask' or 'block' mode */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="border-b border-border">
<div className="max-w-4xl mx-auto px-6 py-2 flex items-center justify-center gap-4">
{/* Load images button - only in 'ask' mode */}
{externalContentPolicy === 'ask' && (
<button
onClick={() => setAllowExternalContent(true)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Image className="w-3.5 h-3.5" />
{t('load_external_content')}
</button>
)}
{/* Trust sender button - in both 'ask' and 'block' modes */}
{email.from?.[0]?.email && (
<>
{externalContentPolicy === 'ask' && <span className="text-muted-foreground/50">|</span>}
<button
onClick={() => {
const senderEmail = email.from?.[0]?.email;
if (senderEmail) {
addTrustedSender(senderEmail);
setAllowExternalContent(true);
}
{/* Unified Notification Banner - External Content + Unsubscribe */}
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe)) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-4xl mx-auto px-6 py-1.5">
<div className="flex flex-col md:flex-row md:items-center md:justify-center gap-3 isolate">
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-center gap-3 flex-wrap">
{/* Load images button - only in 'ask' mode */}
{externalContentPolicy === 'ask' && (
<button
onClick={() => setAllowExternalContent(true)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
<Image className="w-3.5 h-3.5" />
{t('load_external_content')}
</button>
)}
{/* Trust sender button - in both 'ask' and 'block' modes */}
{email.from?.[0]?.email && (
<button
onClick={() => {
const senderEmail = email.from?.[0]?.email;
if (senderEmail) {
addTrustedSender(senderEmail);
setAllowExternalContent(true);
}
}}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('trust_sender')}
</button>
)}
</div>
)}
{/* Unsubscribe Controls */}
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t('trust_sender')}
</button>
</>
)}
/>
)}
</div>
</div>
</div>
)}
+5 -13
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn } from "@/lib/utils";
@@ -260,24 +261,15 @@ function EmailCard({
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote');
const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2;
const hasBrTags = tempDiv.querySelectorAll('br').length > 0;
useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags);
// Use safe parsing instead of innerHTML to detect rich formatting
useHtmlVersion = hasRichFormatting(htmlContent);
}
if (useHtmlVersion && htmlContent) {
let blockedExternalContent = false;
const sanitizeConfig = {
ADD_TAGS: ['style'],
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link', 'base'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit'],
};
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
if (!allowExternal) {
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
+3 -1
View File
@@ -9,6 +9,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { getThreadColorTag } from "@/lib/thread-utils";
import { ThreadEmailItem } from "./thread-email-item";
import { useTranslations } from "next-intl";
interface ThreadListItemProps {
thread: ThreadGroup;
@@ -44,6 +45,7 @@ export function ThreadListItem({
onContextMenu,
onOpenConversation,
}: ThreadListItemProps) {
const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
@@ -235,7 +237,7 @@ export function ThreadListItem({
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Loading conversation...
{t('loading')}
</div>
) : (
emailsToShow.map((email, index) => (
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useState } from 'react';
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { isValidUnsubscribeUrl } from '@/lib/validation';
interface UnsubscribeBannerProps {
listUnsubscribe: {
http?: string;
mailto?: string;
preferred?: 'http' | 'mailto';
};
senderEmail: string;
onDismiss: () => void;
}
export function UnsubscribeBanner({
listUnsubscribe,
senderEmail: _senderEmail,
onDismiss
}: UnsubscribeBannerProps) {
const t = useTranslations();
const [showConfirm, setShowConfirm] = useState(false);
const [processing, setProcessing] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState(false);
const unsubMethod = listUnsubscribe.preferred;
const unsubUrl = unsubMethod === 'http'
? listUnsubscribe.http
: listUnsubscribe.mailto;
if (!unsubUrl || !unsubMethod) {
return null;
}
const handleUnsubscribe = async () => {
if (!isValidUnsubscribeUrl(unsubUrl)) {
setError(true);
setProcessing(false);
return;
}
setProcessing(true);
try {
if (unsubMethod === 'http') {
window.open(unsubUrl, '_blank', 'noopener,noreferrer');
setSuccess(true);
setProcessing(false);
setTimeout(onDismiss, 3000);
} else {
const link = document.createElement('a');
link.href = unsubUrl;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setSuccess(true);
setProcessing(false);
setTimeout(onDismiss, 3000);
}
} catch (err) {
console.error('Unsubscribe error:', err);
setError(true);
setProcessing(false);
}
};
if (success) {
return (
<div className="flex items-center gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground">
{t(unsubMethod === 'http'
? 'email_viewer.unsubscribe_banner.success_http'
: 'email_viewer.unsubscribe_banner.success_mailto'
)}
</span>
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 flex-wrap">
<AlertCircle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{t('email_viewer.unsubscribe_banner.error')}
</span>
<button
onClick={onDismiss}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.dismiss')}
</button>
</div>
);
}
return (
<div className="flex items-center gap-2 flex-wrap">
{showConfirm ? (
<>
<span className="text-sm text-muted-foreground">
{t('email_viewer.unsubscribe_banner.confirm_title')}
</span>
<button
onClick={handleUnsubscribe}
disabled={processing}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] md:min-h-0"
>
{processing && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{t('email_viewer.unsubscribe_banner.confirm_button')}
</button>
<button
onClick={() => setShowConfirm(false)}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.cancel')}
</button>
</>
) : (
<button
onClick={() => setShowConfirm(true)}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.button')}
</button>
)}
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
interface IdentityFormData {
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
}
interface IdentityFormProps {
identity?: Identity;
onSave: (data: IdentityFormData) => Promise<void>;
onCancel: () => void;
}
export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps) {
const t = useTranslations('identities.form');
const tValidation = useTranslations('identities.validation_errors');
const tDisplay = useTranslations('identities.display');
const isEditing = !!identity;
const [formData, setFormData] = useState<IdentityFormData>({
name: identity?.name || '',
email: identity?.email || '',
replyTo: identity?.replyTo,
bcc: identity?.bcc,
textSignature: identity?.textSignature || '',
htmlSignature: identity?.htmlSignature || '',
});
const [replyToInput, setReplyToInput] = useState(
identity?.replyTo?.map(a => a.email).join(', ') || ''
);
const [bccInput, setBccInput] = useState(
identity?.bcc?.map(a => a.email).join(', ') || ''
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const parseEmailList = (input: string): EmailAddress[] | undefined => {
if (!input.trim()) return undefined;
const emails = input.split(',').map(e => e.trim()).filter(Boolean);
return emails.map(email => ({ email }));
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = t('name_required');
}
// Use secure email validation
const emailError = getEmailValidationError(formData.email);
if (emailError) {
newErrors.email = emailError;
}
// Validate reply-to email list
if (replyToInput.trim()) {
const validation = validateEmailList(replyToInput);
if (!validation.valid) {
newErrors.replyTo = tValidation('invalid_emails', { emails: validation.invalidEmails.join(', ') });
}
}
// Validate bcc email list
if (bccInput.trim()) {
const validation = validateEmailList(bccInput);
if (!validation.valid) {
newErrors.bcc = tValidation('invalid_emails', { emails: validation.invalidEmails.join(', ') });
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
// Sanitize HTML signature before sending to server
const sanitizedData: IdentityFormData = {
...formData,
replyTo: parseEmailList(replyToInput),
bcc: parseEmailList(bccInput),
htmlSignature: formData.htmlSignature
? sanitizeSignatureHtml(formData.htmlSignature)
: undefined,
};
await onSave(sanitizedData);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
<label htmlFor="identity-name" className="block text-sm font-medium mb-1">
{t('name_label')} <span className="text-destructive">*</span>
</label>
<Input
id="identity-name"
type="text"
maxLength={256}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('name_placeholder')}
disabled={isSubmitting}
className={errors.name ? 'border-destructive' : ''}
aria-describedby={errors.name ? 'name-error' : undefined}
aria-invalid={!!errors.name}
/>
{errors.name && (
<p
id="name-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.name}
</p>
)}
</div>
{/* Email */}
<div>
<label htmlFor="identity-email" className="block text-sm font-medium mb-1">
{t('email_label')} <span className="text-destructive">*</span>
</label>
<Input
id="identity-email"
type="email"
maxLength={254}
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder={t('email_placeholder')}
disabled={isSubmitting || isEditing}
className={errors.email ? 'border-destructive' : ''}
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
/>
{isEditing && (
<p className="text-sm text-muted-foreground mt-1">
{t('email_immutable')}
</p>
)}
{errors.email && (
<p
id="email-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.email}
</p>
)}
</div>
{/* Reply-To */}
<div>
<label htmlFor="identity-reply-to" className="block text-sm font-medium mb-1">
{t('reply_to_label')}
</label>
<Input
id="identity-reply-to"
type="text"
maxLength={512}
value={replyToInput}
onChange={(e) => setReplyToInput(e.target.value)}
placeholder={t('reply_to_placeholder')}
disabled={isSubmitting}
className={errors.replyTo ? 'border-destructive' : ''}
aria-describedby={errors.replyTo ? 'reply-to-error' : undefined}
aria-invalid={!!errors.replyTo}
/>
{errors.replyTo && (
<p
id="reply-to-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.replyTo}
</p>
)}
</div>
{/* BCC */}
<div>
<label htmlFor="identity-bcc" className="block text-sm font-medium mb-1">
{t('bcc_label')}
</label>
<Input
id="identity-bcc"
type="text"
maxLength={512}
value={bccInput}
onChange={(e) => setBccInput(e.target.value)}
placeholder={t('bcc_placeholder')}
disabled={isSubmitting}
className={errors.bcc ? 'border-destructive' : ''}
aria-describedby={errors.bcc ? 'bcc-error' : undefined}
aria-invalid={!!errors.bcc}
/>
{errors.bcc && (
<p
id="bcc-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.bcc}
</p>
)}
</div>
{/* Text Signature */}
<div>
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
{t('text_signature_label')}
</label>
<textarea
id="identity-text-sig"
maxLength={2000}
value={formData.textSignature}
onChange={(e) => setFormData({ ...formData, textSignature: e.target.value })}
rows={3}
disabled={isSubmitting}
aria-label={t('text_signature_label')}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{/* HTML Signature */}
<div>
<label htmlFor="identity-html-sig" className="block text-sm font-medium mb-1">
{t('html_signature_label')}
</label>
<textarea
id="identity-html-sig"
maxLength={5000}
value={formData.htmlSignature}
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
rows={5}
disabled={isSubmitting}
aria-label={t('html_signature_label')}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
{formData.htmlSignature && (
<div className="mt-2 p-2 border rounded bg-muted">
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
<div
dangerouslySetInnerHTML={{
__html: sanitizeSignatureHtml(formData.htmlSignature)
}}
/>
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
{t('cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? isEditing
? t('updating')
: t('creating')
: t('save')}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,302 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { X, Mail, Pencil, Trash2, Plus, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
interface IdentityFormData {
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
}
interface IdentityManagerModalProps {
isOpen: boolean;
onClose: () => void;
}
export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalProps) {
const t = useTranslations('identities');
const tNotif = useTranslations('notifications');
const client = useAuthStore((state) => state.client);
const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Focus trap with Escape handling
const modalRef = useFocusTrap({
isActive: isOpen,
onEscape: () => {
if (isCreating || editingId) {
setIsCreating(false);
setEditingId(null);
} else {
onClose();
}
},
restoreFocus: true,
});
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, onClose, modalRef]);
const handleCreate = useCallback(async (data: IdentityFormData) => {
if (!client) return;
try {
const newIdentity = await client.createIdentity(
data.name,
data.email,
data.replyTo,
data.bcc,
data.textSignature,
data.htmlSignature
);
addIdentity(newIdentity);
setIsCreating(false);
toast.success(tNotif('identity_created'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_create_failed', { error: message }));
throw error;
}
}, [client, addIdentity, t, tNotif]);
const handleUpdate = useCallback(async (identity: Identity, data: IdentityFormData) => {
if (!client) return;
try {
await client.updateIdentity(identity.id, {
name: data.name,
replyTo: data.replyTo,
bcc: data.bcc,
textSignature: data.textSignature,
htmlSignature: data.htmlSignature,
});
updateIdentityLocal(identity.id, data);
setEditingId(null);
toast.success(tNotif('identity_updated'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_update_failed', { error: message }));
throw error;
}
}, [client, updateIdentityLocal, t, tNotif]);
const handleDelete = useCallback(async (identity: Identity) => {
if (!client) return;
if (!identity.mayDelete) {
toast.error(t('cannot_delete'));
return;
}
if (!window.confirm(t('delete_confirm'))) {
return;
}
setDeletingId(identity.id);
try {
await client.deleteIdentity(identity.id);
removeIdentity(identity.id);
toast.success(tNotif('identity_deleted'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_delete_failed', { error: message }));
} finally {
setDeletingId(null);
}
}, [client, removeIdentity, t, tNotif]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="identity-modal-title"
className={cn(
'bg-background border border-border rounded-lg shadow-xl',
'w-full max-w-3xl max-h-[90vh] overflow-hidden',
'animate-in zoom-in-95 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Mail className="w-5 h-5 text-muted-foreground" />
<h2 id="identity-modal-title" className="text-lg font-semibold text-foreground">
{t('modal_title')}
</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
{/* Create New Form */}
{isCreating && (
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
<h3 className="text-sm font-semibold mb-4">{t('create_new')}</h3>
<IdentityForm
onSave={handleCreate}
onCancel={() => setIsCreating(false)}
/>
</div>
)}
{/* Create Button */}
{!isCreating && !editingId && (
<Button
onClick={() => setIsCreating(true)}
className="mb-6 w-full sm:w-auto"
>
<Plus className="w-4 h-4 mr-2" />
{t('create_new')}
</Button>
)}
{/* Identities List */}
<div className="space-y-4">
{identities.map((identity) => (
<div
key={identity.id}
className="border border-border rounded-lg overflow-hidden"
>
{editingId === identity.id ? (
<div className="p-4 bg-muted/30">
<h3 className="text-sm font-semibold mb-4">
{t('edit_identity')}
</h3>
<IdentityForm
identity={identity}
onSave={(data) => handleUpdate(identity, data)}
onCancel={() => setEditingId(null)}
/>
</div>
) : (
<div className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-foreground truncate">
{identity.name}
</h3>
{identities[0]?.id === identity.id && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{t('primary_identity')}
</span>
)}
</div>
<p className="text-sm text-muted-foreground truncate">
{identity.email}
</p>
{/* Additional Info */}
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
{identity.replyTo && identity.replyTo.length > 0 && (
<p>
{t('display.reply_to')} {identity.replyTo.map((a) => a.email).join(', ')}
</p>
)}
{identity.bcc && identity.bcc.length > 0 && (
<p>
{t('display.bcc')} {identity.bcc.map((a) => a.email).join(', ')}
</p>
)}
{identity.textSignature && (
<p className="line-clamp-2">
{t('display.signature')} {identity.textSignature}
</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingId(identity.id)}
disabled={!!editingId || isCreating}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(identity)}
disabled={
!identity.mayDelete ||
!!editingId ||
isCreating ||
deletingId === identity.id
}
className={!identity.mayDelete ? 'opacity-30' : ''}
>
{!identity.mayDelete ? (
<AlertTriangle className="w-4 h-4 text-yellow-500" />
) : (
<Trash2 className="w-4 h-4 text-destructive" />
)}
</Button>
</div>
</div>
{!identity.mayDelete && (
<p className="text-xs text-yellow-600 dark:text-yellow-500 mt-2 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{t('cannot_delete')}
</p>
)}
</div>
)}
</div>
))}
{identities.length === 0 && !isCreating && (
<div className="text-center py-12 text-muted-foreground">
<Mail className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">{t('no_identities')}</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
'use client';
import { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Plus, Tag, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useIdentityStore } from '@/stores/identity-store';
import {
generateSubAddress,
extractDomain,
suggestTagsForDomain,
getTagValidationError,
MAX_TAG_LENGTH,
} from '@/lib/sub-addressing';
interface SubAddressHelperProps {
baseEmail: string;
recipientEmails: string[];
onSelectTag: (tag: string) => void;
disabled?: boolean;
}
export function SubAddressHelper({
baseEmail,
recipientEmails,
onSelectTag,
disabled = false,
}: SubAddressHelperProps) {
const t = useTranslations('identities.sub_address');
const [isOpen, setIsOpen] = useState(false);
const [tag, setTag] = useState('');
const [error, setError] = useState<string | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore();
// Get suggestions based on recipient (memoized for performance)
const suggestions = useMemo(() => {
return recipientEmails
.map(extractDomain)
.filter(Boolean)
.flatMap((domain) => suggestTagsForDomain(domain!))
.filter((tag, index, self) => self.indexOf(tag) === index)
.slice(0, 5);
}, [recipientEmails]);
// Generate preview
const preview = tag ? generateSubAddress(baseEmail, tag) : baseEmail;
// Close popover when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
const handleTagChange = (value: string) => {
setTag(value);
const errorCode = getTagValidationError(value);
// Translate error code to localized message
let errorMessage: string | null = null;
if (errorCode === 'EMPTY') {
errorMessage = t('validation.empty');
} else if (errorCode === 'TOO_LONG') {
errorMessage = t('validation.too_long', { max: MAX_TAG_LENGTH });
} else if (errorCode === 'INVALID_CHARS') {
errorMessage = t('validation.invalid_chars');
}
setError(errorMessage);
};
const handleSelectTag = (selectedTag: string) => {
const errorCode = getTagValidationError(selectedTag);
if (errorCode) {
// Translate error code to localized message
let errorMessage: string | null = null;
if (errorCode === 'EMPTY') {
errorMessage = t('validation.empty');
} else if (errorCode === 'TOO_LONG') {
errorMessage = t('validation.too_long', { max: MAX_TAG_LENGTH });
} else if (errorCode === 'INVALID_CHARS') {
errorMessage = t('validation.invalid_chars');
}
setError(errorMessage);
return;
}
// Add to recent tags and suggestions
addRecentTag(selectedTag);
const domain = recipientEmails.map(extractDomain).find(Boolean);
if (domain) {
addTagSuggestion(domain, selectedTag);
}
onSelectTag(selectedTag);
setIsOpen(false);
setTag('');
setError(null);
};
const handleUseAddress = () => {
if (!tag) return;
handleSelectTag(tag);
};
return (
<div className="relative">
{/* Trigger Button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsOpen(!isOpen)}
disabled={disabled}
title={t('button_tooltip')}
className="h-8 px-2"
>
<Plus className="w-4 h-4 mr-1" />
<Tag className="w-4 h-4" />
</Button>
{/* Popover */}
{isOpen && (
<div
ref={popoverRef}
className={cn(
'absolute top-full left-0 mt-1 z-50',
'bg-background border border-border rounded-lg shadow-lg',
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
)}
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground">
{t('popover_title')}
</h3>
<button
onClick={() => setIsOpen(false)}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tag Input */}
<div className="mb-3">
<Input
type="text"
value={tag}
onChange={(e) => handleTagChange(e.target.value)}
placeholder={t('tag_input_placeholder')}
className={cn(error && 'border-destructive')}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && tag && !error) {
e.preventDefault();
handleUseAddress();
}
}}
/>
{error && (
<p className="text-xs text-destructive mt-1">{error}</p>
)}
</div>
{/* Preview */}
<div className="mb-3 p-2 bg-muted rounded text-sm">
<div className="text-xs text-muted-foreground mb-1">
{t('preview_label')}
</div>
<div className="font-mono text-foreground break-all">
{preview}
</div>
</div>
{/* Recent Tags */}
{subAddress.recentTags.length > 0 && (
<div className="mb-3">
<div className="text-xs text-muted-foreground mb-2">
{t('recent_tags')}
</div>
<div className="flex flex-wrap gap-1">
{subAddress.recentTags.slice(0, 5).map((recentTag) => (
<button
key={recentTag}
onClick={() => handleSelectTag(recentTag)}
className="px-2 py-1 text-xs rounded bg-secondary hover:bg-accent text-foreground transition-colors"
>
{recentTag}
</button>
))}
</div>
</div>
)}
{/* Suggested Tags */}
{suggestions.length > 0 && (
<div className="mb-3">
<div className="text-xs text-muted-foreground mb-2">
{t('suggested_tags')}
</div>
<div className="flex flex-wrap gap-1">
{suggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => handleSelectTag(suggestion)}
className="px-2 py-1 text-xs rounded bg-primary/10 hover:bg-primary/20 text-primary transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
)}
{/* Help Text */}
<div className="mb-3 text-xs text-muted-foreground">
{t('help_text')}
</div>
{/* Use Address Button */}
<Button
onClick={handleUseAddress}
disabled={!tag || !!error}
className="w-full"
size="sm"
>
{t('use_address')}
</Button>
</div>
)}
</div>
);
}
+7 -3
View File
@@ -4,6 +4,7 @@ import { Menu, ArrowLeft, Plus, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores/ui-store";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
interface MobileHeaderProps {
title: string;
@@ -22,6 +23,7 @@ export function MobileHeader({
onSearch,
className,
}: MobileHeaderProps) {
const t = useTranslations('sidebar');
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
const handleLeftAction = () => {
@@ -72,7 +74,7 @@ export function MobileHeader({
size="icon"
onClick={onSearch}
className="h-10 w-10"
aria-label="Search"
aria-label={t('mobile.search')}
>
<Search className="h-5 w-5" />
</Button>
@@ -83,7 +85,7 @@ export function MobileHeader({
size="icon"
onClick={onCompose}
className="h-10 w-10 text-primary"
aria-label="Compose"
aria-label={t('mobile.compose')}
>
<Plus className="h-5 w-5" />
</Button>
@@ -111,6 +113,8 @@ export function MobileViewerHeader({
onArchive: _onArchive,
className,
}: MobileViewerHeaderProps) {
const t = useTranslations('sidebar');
return (
<header
className={cn(
@@ -124,7 +128,7 @@ export function MobileViewerHeader({
size="icon"
onClick={onBack}
className="h-10 w-10"
aria-label="Go back"
aria-label={t('mobile.go_back')}
>
<ArrowLeft className="h-5 w-5" />
</Button>
+19 -1
View File
@@ -30,6 +30,7 @@ import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
import { toast } from "@/stores/toast-store";
interface SidebarProps {
mailboxes: Mailbox[];
@@ -96,6 +97,7 @@ function MailboxTreeItem({
isCollapsed: boolean;
}) {
const t = useTranslations('sidebar');
const tNotifications = useTranslations('notifications');
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
@@ -106,6 +108,22 @@ function MailboxTreeItem({
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
mailbox: node,
onSuccess: (count, mailboxName) => {
if (count === 1) {
toast.success(
tNotifications('email_moved'),
tNotifications('moved_to_mailbox', { mailbox: mailboxName })
);
} else {
toast.success(
tNotifications('emails_moved', { count }),
tNotifications('moved_to_mailbox', { mailbox: mailboxName })
);
}
},
onError: () => {
toast.error(tNotifications('move_failed'), tNotifications('move_error'));
},
});
return (
@@ -371,7 +389,7 @@ export function Sidebar({
onClearSearch?.();
}}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear search"
aria-label={t('clear_search')}
>
<X className="w-4 h-4" />
</button>
+12
View File
@@ -5,11 +5,23 @@ import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
import enMessages from '@/locales/en/common.json';
import frMessages from '@/locales/fr/common.json';
import jaMessages from '@/locales/ja/common.json';
import esMessages from '@/locales/es/common.json';
import itMessages from '@/locales/it/common.json';
import deMessages from '@/locales/de/common.json';
import nlMessages from '@/locales/nl/common.json';
import ptMessages from '@/locales/pt/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
en: enMessages,
fr: frMessages,
ja: jaMessages,
es: esMessages,
it: itMessages,
de: deMessages,
nl: nlMessages,
pt: ptMessages,
};
interface IntlProviderProps {
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { SettingsSection, SettingItem } from './settings-section';
import { IdentityManagerModal } from '@/components/identity/identity-manager-modal';
import { useIdentityStore } from '@/stores/identity-store';
export function IdentitySettings() {
const t = useTranslations('settings.identities');
const { identities } = useIdentityStore();
const [showModal, setShowModal] = useState(false);
return (
<>
<SettingsSection title={t('title')} description={t('description')}>
{/* Identity Count */}
<SettingItem
label={t('identities_count.label')}
description={t('identities_count.description')}
>
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">
{identities.length === 0
? t('identities_count.count_zero')
: identities.length === 1
? t('identities_count.count_one')
: t('identities_count.count_other', { count: identities.length })}
</span>
<Button onClick={() => setShowModal(true)} size="sm">
{t('manage')}
</Button>
</div>
</SettingItem>
{/* Sub-Addressing Info */}
<SettingItem
label={t('sub_addressing.label')}
description={t('sub_addressing.description')}
>
<Button variant="outline" size="sm" onClick={() => setShowModal(true)}>
{t('sub_addressing.learn_more')}
</Button>
</SettingItem>
</SettingsSection>
{/* Identity Manager Modal */}
<IdentityManagerModal
isOpen={showModal}
onClose={() => setShowModal(false)}
/>
</>
);
}
+2 -1
View File
@@ -111,7 +111,8 @@ export function Select({ value, onChange, options }: SelectProps) {
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
dir="auto"
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
+15 -36
View File
@@ -1,52 +1,31 @@
"use client";
import { useLocale, useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
import { useLocale } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
import { Select } from '@/components/settings/settings-section';
export function LanguageSwitcher({ className }: { className?: string }) {
const currentLocale = useLocale();
const t = useTranslations('language');
const setLocale = useLocaleStore((state) => state.setLocale);
const handleLanguageChange = (newLocale: string) => {
if (newLocale === currentLocale) return;
// Update locale in store (persisted to localStorage via Zustand)
// IntlProvider handles the translation switch
setLocale(newLocale);
};
const languages = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' }
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
{ value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' },
{ value: 'de', label: 'Deutsch' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pt', label: 'Português' }
];
return (
<div
className={cn("flex gap-2", className)}
role="radiogroup"
aria-label={t('select_language')}
>
{languages.map((lang) => (
<button
key={lang.value}
type="button"
role="radio"
aria-checked={currentLocale === lang.value}
aria-label={t(lang.value === 'en' ? 'switch_to_english' : 'switch_to_french')}
onClick={() => handleLanguageChange(lang.value)}
className={cn(
"px-3 py-1.5 text-xs rounded transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
currentLocale === lang.value
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent text-foreground"
)}
>
{lang.label}
</button>
))}
<div className={className}>
<Select
value={currentLocale}
onChange={setLocale}
options={languages}
/>
</div>
);
}