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