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' &&
|}
-
@@ -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')}
>
@@ -111,6 +113,8 @@ export function MobileViewerHeader({
onArchive: _onArchive,
className,
}: MobileViewerHeaderProps) {
+ const t = useTranslations('sidebar');
+
return (
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 83f46829..97367652 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -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')}
>
diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx
index 895bbebc..9454e24b 100644
--- a/components/providers/intl-provider.tsx
+++ b/components/providers/intl-provider.tsx
@@ -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 {
diff --git a/components/settings/identity-settings.tsx b/components/settings/identity-settings.tsx
new file mode 100644
index 00000000..8bfec2dc
--- /dev/null
+++ b/components/settings/identity-settings.tsx
@@ -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 (
+ <>
+
+ {/* Identity Count */}
+
+
+
+ {identities.length === 0
+ ? t('identities_count.count_zero')
+ : identities.length === 1
+ ? t('identities_count.count_one')
+ : t('identities_count.count_other', { count: identities.length })}
+
+ setShowModal(true)} size="sm">
+ {t('manage')}
+
+
+
+
+ {/* Sub-Addressing Info */}
+
+ setShowModal(true)}>
+ {t('sub_addressing.learn_more')}
+
+
+
+
+ {/* Identity Manager Modal */}
+ setShowModal(false)}
+ />
+ >
+ );
+}
diff --git a/components/settings/settings-section.tsx b/components/settings/settings-section.tsx
index 640d0b9f..574d7a24 100644
--- a/components/settings/settings-section.tsx
+++ b/components/settings/settings-section.tsx
@@ -111,7 +111,8 @@ export function Select({ value, onChange, options }: SelectProps) {