diff --git a/README.md b/README.md index 4a2191fd..04b8c743 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - Shared folder support with proper permissions ### Internationalization -- English and French language support +- 8 language support: English, French, Japanese, Spanish, Italian, German, Dutch, Portuguese - Automatic browser language detection - Persistent language preference diff --git a/ROADMAP.md b/ROADMAP.md index 579a173c..efb46e62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -70,14 +70,37 @@ This document tracks the development status and planned features for JMAP Webmai ### Internationalization - [x] English language support - [x] French language support +- [x] Japanese language support +- [x] Spanish language support +- [x] Italian language support +- [x] German language support +- [x] Dutch language support +- [x] Portuguese language support - [x] Automatic browser language detection - [x] Language preference persistence -### Security +### Security & Accessibility - [x] External content blocked by default - [x] HTML sanitization with DOMPurify - [x] User control for loading external content - [x] Trusted senders list for automatic image loading +- [x] Dark mode email readability (intelligent color transformation) +- [x] WCAG 2.0 Level AA color contrast compliance +- [x] Newsletter unsubscribe support (RFC 2369) +- [x] XSS attack prevention with comprehensive validation + +### Identity Management +- [x] Multiple sender identities (name, email, signature) +- [x] Sub-addressing support (user+tag@domain.com) +- [x] Per-identity signatures +- [x] Identity badges in email viewer and list +- [x] Tag suggestions based on context + +### Testing +- [x] Unit tests for validation utilities (57 tests) +- [x] Unit tests for email sanitization +- [x] Unit tests for color transformation +- [x] XSS attack vector testing ### Deployment - [x] Runtime environment variables (Docker-friendly configuration) @@ -98,9 +121,7 @@ This document tracks the development status and planned features for JMAP Webmai - [ ] Email filters and rules - [ ] Calendar integration (JMAP Calendars) - [ ] Email templates -- [ ] Signature management - [ ] Vacation responder settings -- [ ] Email aliases support - [ ] Advanced search with filters - [ ] Email encryption (PGP/GPG) @@ -111,8 +132,7 @@ This document tracks the development status and planned features for JMAP Webmai - [ ] Service worker for offline support - [ ] Lazy loading for attachments -### Testing -- [ ] Unit tests for utilities +### Testing (Remaining) - [ ] Component tests - [ ] E2E tests with Playwright - [ ] Accessibility testing diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index dc961432..82141713 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -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" /> @@ -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} /> diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index b782cbf3..2c97aec6 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -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' && } {activeTab === 'email' && } {activeTab === 'account' && } + {activeTab === 'identities' && } {activeTab === 'advanced' && } diff --git a/app/global-error.tsx b/app/global-error.tsx index 6353f695..5e389844 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -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 and 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 and tags as it replaces + * the root layout entirely. */ export default function GlobalError({ error, diff --git a/app/globals.css b/app/globals.css index c1796a9e..6013a229 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 05445204..fdb9ca93 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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>([]); const fileInputRef = useRef(null); const [selectedIdentityId, setSelectedIdentityId] = useState(null); + const [subAddressTag, setSubAddressTag] = useState(''); 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 */}
{t('from')}: - {identities.length > 1 ? ( - - ) : ( - - {primaryIdentity?.name - ? `${primaryIdentity.name} <${primaryIdentity.email}>` - : primaryIdentity?.email || ''} - - )} +
+ {identities.length > 1 ? ( + + ) : ( + + {subAddressTag ? ( + + {generateSubAddress(primaryIdentity?.email || '', subAddressTag)} + + ) : ( + <> + {primaryIdentity?.name + ? `${primaryIdentity.name} <${primaryIdentity.email}>` + : primaryIdentity?.email || ''} + + )} + + )} + id.id === selectedIdentityId)?.email + : primaryIdentity?.email) || '' + } + recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)} + onSelectTag={setSubAddressTag} + /> + {subAddressTag && ( + + )} +
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 595da578..e582dcc6 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -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; 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({ + {/* Spam - contextual based on folder */} + + handleAction( + showBatchActions + ? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!) + : (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!) + ) + } + disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)} + destructive={!isInJunkFolder} + /> + + + {/* Set color submenu - only for single email */} {!showBatchActions && ( diff --git a/components/email/email-identity-badge.tsx b/components/email/email-identity-badge.tsx new file mode 100644 index 00000000..d320d2ea --- /dev/null +++ b/components/email/email-identity-badge.tsx @@ -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 ( +
+ + +{displayTag} +
+ ); + } + + if ( + matchingIdentity && + matchingIdentity.name && + matchingIdentity.name !== matchingIdentity.email && + matchingIdentity.name !== fromAddress + ) { + return ( +
+ + {matchingIdentity.name} +
+ ); + } + + return null; + } + + // Full view for email viewer - now shows compact badges only + return ( +
+ {/* Sub-address tag badge */} + {displayTag && ( +
+ + {t('subaddress_tag', { tag: displayTag })} +
+ )} + + {/* Identity badge (only if identity has a name and no sub-address tag) */} + {!displayTag && + matchingIdentity && + matchingIdentity.name && + matchingIdentity.name !== matchingIdentity.email && + matchingIdentity.name !== fromAddress && ( +
+ + {t('identity_short', { name: matchingIdentity.name })} +
+ )} +
+ ); +} diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 5d31f31d..c3bf17ab 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -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 | 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 )} + {email.hasAttachment && ( )} @@ -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')}
{/* Third Line: Preview (controlled by showPreview setting) */} diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 5dacae7b..ca9747a6 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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({ )}

- {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')}

@@ -279,7 +287,7 @@ export function EmailList({
- Loading... + {t('loading')}
)} @@ -290,8 +298,8 @@ export function EmailList({ ) : emails.length === 0 && !isLoading ? (
-

No emails in this mailbox

-

New messages will appear here

+

{t('no_emails')}

+

{t('no_emails_description')}

) : (
@@ -315,12 +323,12 @@ export function EmailList({ {isLoadingMore && hasMoreEmails && (
- Loading more emails... + {t('loading_more')}
)} {!hasMoreEmails && emails.length > 0 && (
- No more emails to load + {t('no_more_emails')}
)}
@@ -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')); + } + } + }} /> )} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 1168ba88..e0aa875a 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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; + 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 | undefined) => { if (!keywords) return null; for (const key of Object.keys(keywords)) { @@ -118,6 +116,42 @@ const getCurrentColor = (keywords: Record | 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 => { + 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>( + () => { + 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, '
'); return { - html: `
${previewHtml}
`, + html: `
${previewHtml}
`, isHtml: false }; } return { - html: '

No content available

', + html: '

No content available

', 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({ )}

- {email.subject || "(no subject)"} + {email.subject || t('no_subject')}

@@ -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 @@ -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')} > + + {/* Spam/Not Spam Button - Desktop only, contextual based on folder */} + {(onMarkAsSpam || onUndoSpam) && ( + + )} + @@ -768,6 +856,30 @@ export function EmailViewer({ {t('print')} + {/* Separator */} +
+ {/* Spam action - contextual */} + {(onMarkAsSpam || onUndoSpam) && ( + + )}
@@ -786,40 +898,39 @@ export function EmailViewer({ />
+ {/* Sender line with compact badges */}
{sender?.name || sender?.email || t('unknown_sender')} - {sender?.email && sender?.name && ( - - <{sender.email}> - - )} +
+ {/* Recipient section - separate line */}
{email.to && email.to.length > 0 && (
- To: + {t('recipient_to_prefix')} - {email.to.slice(0, 2).map(r => r.name || r.email).join(", ")} - {email.to.length > 2 && ( - - )} + {formatRecipients(email.to, currentUserEmail, t)} + {email.to.length > 2 && ( + + )}
)} - {(email.cc && email.cc.length > 0) && ( + {email.cc && email.cc.length > 0 && (
CC: - {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}`}
)} @@ -1139,72 +1250,94 @@ export function EmailViewer({ className="shadow-sm w-10 h-10" />
+ {/* Mobile 2-line layout */}
- + {sender?.name || sender?.email || t('unknown_sender')} + +
+
{sender?.email && sender?.name && ( - - <{sender.email}> - + <> + {sender.email} + · + )} -
-
{email.to && email.to.length > 0 && ( -
- To: - - {email.to.slice(0, 2).map(r => r.name || r.email).join(", ")} - {email.to.length > 2 && ` +${email.to.length - 2}`} + <> + → {t('recipient_to_prefix')} + + {formatRecipients(email.to, currentUserEmail, t)} -
- )} - {email.cc && email.cc.length > 0 && ( -
- CC: - - {email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")} - {email.cc.length > 2 && ` +${email.cc.length - 2}`} - -
+ )}
+ {/* CC line (mobile - only if present) */} + {email.cc && email.cc.length > 0 && ( +
+ CC: + + {email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")} + {email.cc.length > 2 && ` +${email.cc.length - 2}`} + +
+ )}
- {/* External Content Banner - show in 'ask' or 'block' mode */} - {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( -
-
- {/* Load images button - only in 'ask' mode */} - {externalContentPolicy === 'ask' && ( - - )} - {/* Trust sender button - in both 'ask' and 'block' modes */} - {email.from?.[0]?.email && ( - <> - {externalContentPolicy === 'ask' && |} - + )} + {/* Trust sender button - in both 'ask' and 'block' modes */} + {email.from?.[0]?.email && ( + + )} +
+ )} + + {/* Unsubscribe Controls */} + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( + { + 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')} - - - )} + /> + )} +
)} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index bfacdd26..d3b5430e 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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) => { diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index fbdcc622..2c320349 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -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 ? (
- Loading conversation... + {t('loading')}
) : ( emailsToShow.map((email, index) => ( diff --git a/components/email/unsubscribe-banner.tsx b/components/email/unsubscribe-banner.tsx new file mode 100644 index 00000000..331c47fc --- /dev/null +++ b/components/email/unsubscribe-banner.tsx @@ -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 ( +
+ + + {t(unsubMethod === 'http' + ? 'email_viewer.unsubscribe_banner.success_http' + : 'email_viewer.unsubscribe_banner.success_mailto' + )} + +
+ ); + } + + if (error) { + return ( +
+ + + {t('email_viewer.unsubscribe_banner.error')} + + +
+ ); + } + + return ( +
+ {showConfirm ? ( + <> + + {t('email_viewer.unsubscribe_banner.confirm_title')} + + + + + ) : ( + + )} +
+ ); +} diff --git a/components/identity/identity-form.tsx b/components/identity/identity-form.tsx new file mode 100644 index 00000000..25e7dc55 --- /dev/null +++ b/components/identity/identity-form.tsx @@ -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; + 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({ + 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>({}); + + 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 = {}; + + 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 ( +
+ {/* Name */} +
+ + 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 && ( + + )} +
+ + {/* Email */} +
+ + 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 && ( +

+ {t('email_immutable')} +

+ )} + {errors.email && ( + + )} +
+ + {/* Reply-To */} +
+ + 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 && ( + + )} +
+ + {/* BCC */} +
+ + 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 && ( + + )} +
+ + {/* Text Signature */} +
+ +