feat: expand internationalization and add identity management

This release significantly expands internationalization support and adds comprehensive identity management features.

Internationalization (i18n):
- Add 5 new languages: Spanish, Italian, German, Dutch, Portuguese
- Expand from 3 to 8 total supported languages
- Redesign language switcher for better scalability (dropdown UI)
- Complete translations for all features across all languages

Identity Management:
- Multiple sender identities with per-identity signatures
- Sub-addressing support (user+tag@domain.com)
- Context-aware tag suggestions for sub-addresses
- Identity badges in email viewer and list
- Full CRUD operations for managing identities

Newsletter Management:
- RFC 2369 List-Unsubscribe support (one-click unsubscribe)
- HTTP and mailto unsubscribe methods
- Security validation prevents XSS attacks
- Two-step confirmation with persistent dismissal

Security & Accessibility:
- Dark mode email readability (intelligent color transformation)
- WCAG 2.0 Level AA color contrast compliance
- Comprehensive XSS prevention with validation utilities
- Unit test coverage for security-critical code (57 validation tests)

Testing:
- Add unit tests for validation utilities
- Add unit tests for email sanitization
- Add unit tests for color transformation
- Full test coverage for XSS attack vectors
This commit is contained in:
Matthieu MALVACHE
2026-01-08 22:15:32 +01:00
committed by Matthieu MALVACHE
parent 0d68851b63
commit 5d273e2109
49 changed files with 11018 additions and 432 deletions
+84 -25
View File
@@ -7,6 +7,8 @@ import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
interface EmailComposerProps {
onSend?: (data: {
@@ -42,6 +44,7 @@ export function EmailComposer({
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
// Initialize with reply/forward data if provided
const getInitialTo = () => {
@@ -64,9 +67,11 @@ export function EmailComposer({
const getInitialSubject = () => {
if (!replyTo?.subject) return "";
if (mode === 'forward') {
return `Fwd: ${replyTo.subject.replace(/^(Fwd:\s*)+/i, '')}`;
const fwdPrefix = t('prefix.forward');
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `Re: ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
const rePrefix = t('prefix.reply');
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
}
return "";
};
@@ -76,7 +81,7 @@ export function EmailComposer({
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : "Unknown";
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
if (mode === 'forward') {
return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
@@ -100,6 +105,7 @@ export function EmailComposer({
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [subAddressTag, setSubAddressTag] = useState<string>('');
const { client, identities, primaryIdentity } = useAuthStore();
@@ -176,7 +182,7 @@ export function EmailComposer({
}));
// Create a hash of current data to compare with last saved
const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments });
const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments, identityId: selectedIdentityId, subAddressTag });
// Only save if data has changed
if (currentData === lastSavedDataRef.current) {
@@ -185,13 +191,27 @@ export function EmailComposer({
setSaveStatus('saving');
// Get the selected identity or primary identity
const currentIdentity = selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity;
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
: currentIdentity.email
: undefined;
try {
const savedDraftId = await client.createDraft(
toAddresses,
subject || "(No subject)",
subject || t('no_subject'),
body,
ccAddresses,
bccAddresses,
currentIdentity?.id,
fromEmail,
draftId || undefined,
uploadedAttachments
);
@@ -263,6 +283,13 @@ export function EmailComposer({
? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity;
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
: currentIdentity.email
: undefined;
onSend?.({
to: toAddresses,
cc: ccAddresses,
@@ -270,7 +297,7 @@ export function EmailComposer({
subject,
body,
draftId: finalDraftId || undefined,
fromEmail: currentIdentity?.email,
fromEmail,
identityId: currentIdentity?.id,
});
@@ -281,6 +308,7 @@ export function EmailComposer({
setSubject("");
setBody("");
setDraftId(null);
setSubAddressTag("");
}
};
@@ -341,25 +369,56 @@ export function EmailComposer({
{/* From field - show dropdown if multiple identities, otherwise display email */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">{t('from')}:</span>
{identities.length > 1 ? (
<select
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors"
>
{identities.map((identity) => (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
</option>
))}
</select>
) : (
<span className="text-sm text-foreground">
{primaryIdentity?.name
? `${primaryIdentity.name} <${primaryIdentity.email}>`
: primaryIdentity?.email || ''}
</span>
)}
<div className="flex-1 flex items-center gap-1">
{identities.length > 1 ? (
<select
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors"
>
{identities.map((identity) => (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
</option>
))}
</select>
) : (
<span className="text-sm text-foreground flex-1">
{subAddressTag ? (
<span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
</span>
) : (
<>
{primaryIdentity?.name
? `${primaryIdentity.name} <${primaryIdentity.email}>`
: primaryIdentity?.email || ''}
</>
)}
</span>
)}
<SubAddressHelper
baseEmail={
(selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)?.email
: primaryIdentity?.email) || ''
}
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
onSelectTag={setSubAddressTag}
/>
{subAddressTag && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSubAddressTag('')}
className="h-6 px-2 text-xs"
title={t('remove_sub_address')}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2">
+42 -11
View File
@@ -25,6 +25,8 @@ import {
Send,
File,
Folder,
ShieldAlert,
ShieldCheck,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -41,6 +43,7 @@ interface EmailContextMenuProps {
menuRef: React.RefObject<HTMLDivElement | null>;
mailboxes: Mailbox[];
selectedMailbox: string;
currentMailboxRole?: string;
isMultiSelect?: boolean;
selectedCount?: number;
// Single email actions
@@ -53,23 +56,16 @@ interface EmailContextMenuProps {
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMoveToMailbox?: (mailboxId: string) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
// Batch actions
onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void;
onBatchMoveToMailbox?: (mailboxId: string) => void;
onBatchMarkAsSpam?: () => void;
onBatchUndoSpam?: () => void;
}
// Color options for email tags
const colorOptions = [
{ name: "Red", value: "red", color: "bg-red-500" },
{ name: "Orange", value: "orange", color: "bg-orange-500" },
{ name: "Yellow", value: "yellow", color: "bg-yellow-500" },
{ name: "Green", value: "green", color: "bg-green-500" },
{ name: "Blue", value: "blue", color: "bg-blue-500" },
{ name: "Purple", value: "purple", color: "bg-purple-500" },
{ name: "Pink", value: "pink", color: "bg-pink-500" },
];
// Get mailbox icon based on role
const getMailboxIcon = (role?: string) => {
switch (role) {
@@ -107,6 +103,7 @@ export function EmailContextMenu({
menuRef,
mailboxes,
selectedMailbox,
currentMailboxRole,
isMultiSelect = false,
selectedCount = 1,
onReply,
@@ -118,15 +115,32 @@ export function EmailContextMenu({
onArchive,
onSetColorTag,
onMoveToMailbox,
onMarkAsSpam,
onUndoSpam,
onBatchMarkAsRead,
onBatchDelete,
onBatchMoveToMailbox,
onBatchMarkAsSpam,
onBatchUndoSpam,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const tColor = useTranslations("email_viewer.color_tag");
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const currentColor = getCurrentColor(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Color options for email tags (using translations)
const colorOptions = [
{ name: tColor("red"), value: "red", color: "bg-red-500" },
{ name: tColor("orange"), value: "orange", color: "bg-orange-500" },
{ name: tColor("yellow"), value: "yellow", color: "bg-yellow-500" },
{ name: tColor("green"), value: "green", color: "bg-green-500" },
{ name: tColor("blue"), value: "blue", color: "bg-blue-500" },
{ name: tColor("purple"), value: "purple", color: "bg-purple-500" },
{ name: tColor("pink"), value: "pink", color: "bg-pink-500" },
];
// Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes)
const moveTargets = mailboxes.filter(
@@ -239,6 +253,23 @@ export function EmailContextMenu({
<ContextMenuSeparator />
{/* Spam - contextual based on folder */}
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
onClick={() =>
handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
<ContextMenuSeparator />
{/* Set color submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Palette} label={t("color_tag")}>
+142
View File
@@ -0,0 +1,142 @@
'use client';
import { useTranslations } from 'next-intl';
import { Mail, Tag } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Email, Identity } from '@/lib/jmap/types';
import { parseSubAddress } from '@/lib/sub-addressing';
interface EmailIdentityBadgeProps {
email: Email;
identities: Identity[];
compact?: boolean;
className?: string;
}
export function EmailIdentityBadge({
email,
identities,
compact = false,
className,
}: EmailIdentityBadgeProps) {
const t = useTranslations('identities.badge');
const fromAddress = email.from?.[0]?.email;
if (!fromAddress) return null;
// Parse the from address to check for sub-addressing
const parsedFrom = parseSubAddress(fromAddress);
// Find matching identity (email sent BY the user)
const matchingIdentity = identities.find(
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
);
// Check if email was sent TO a sub-address (received email)
let receivedToTag: string | null = null;
if (!matchingIdentity) {
// Check all TO addresses for sub-address tags matching user's identities
for (const recipient of email.to || []) {
const parsedTo = parseSubAddress(recipient.email);
if (parsedTo.tag) {
// Check if this base email matches any of the user's identities
const matchingToIdentity = identities.find(
(identity) => identity.email === `${parsedTo.baseUser}@${parsedTo.domain}`
);
if (matchingToIdentity) {
receivedToTag = parsedTo.tag;
break;
}
}
}
}
// Determine which tag to display (sent or received)
const displayTag = matchingIdentity ? parsedFrom.tag : receivedToTag;
// Don't show badge if not from user's identity and not to user's sub-address
if (!matchingIdentity && !receivedToTag) return null;
if (compact) {
// Compact view for email list
if (displayTag) {
return (
<div
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs',
'bg-primary/10 text-primary',
className
)}
title={t('sub_address_tag', { tag: displayTag })}
>
<Tag className="w-3 h-3" />
<span className="font-mono">+{displayTag}</span>
</div>
);
}
if (
matchingIdentity &&
matchingIdentity.name &&
matchingIdentity.name !== matchingIdentity.email &&
matchingIdentity.name !== fromAddress
) {
return (
<div
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs',
'bg-secondary text-muted-foreground',
className
)}
title={t('identity_name', { name: matchingIdentity.name })}
>
<Mail className="w-3 h-3" />
<span className="truncate max-w-[100px]">{matchingIdentity.name}</span>
</div>
);
}
return null;
}
// Full view for email viewer - now shows compact badges only
return (
<div className={cn('inline-flex items-center gap-2', className)}>
{/* Sub-address tag badge */}
{displayTag && (
<div
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md',
'bg-primary/10 text-primary border border-primary/20',
'text-xs font-semibold'
)}
title={t('sub_address_tag', { tag: displayTag })}
aria-label={t('sub_address_tag', { tag: displayTag })}
>
<Tag className="w-3 h-3" />
<span className="font-mono">{t('subaddress_tag', { tag: displayTag })}</span>
</div>
)}
{/* Identity badge (only if identity has a name and no sub-address tag) */}
{!displayTag &&
matchingIdentity &&
matchingIdentity.name &&
matchingIdentity.name !== matchingIdentity.email &&
matchingIdentity.name !== fromAddress && (
<div
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md',
'bg-secondary text-muted-foreground border border-border',
'text-xs font-medium'
)}
title={t('identity_name', { name: matchingIdentity.name })}
aria-label={t('identity_name', { name: matchingIdentity.name })}
>
<Mail className="w-3 h-3" />
<span>{t('identity_short', { name: matchingIdentity.name })}</span>
</div>
)}
</div>
);
}
+7 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
@@ -7,7 +8,9 @@ import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { EmailIdentityBadge } from "./email-identity-badge";
interface EmailListItemProps {
email: Email;
@@ -39,8 +42,10 @@ const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
};
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
@@ -145,6 +150,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
Important
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
@@ -167,7 +173,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
{email.subject || t('no_subject')}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
+56 -15
View File
@@ -11,6 +11,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useTranslations } from "next-intl";
interface EmailListProps {
emails: Email[];
@@ -30,6 +31,8 @@ interface EmailListProps {
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
}
export function EmailList({
@@ -47,8 +50,11 @@ export function EmailList({
onDelete,
onArchive,
onSetColorTag,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
}: EmailListProps) {
const t = useTranslations('email_list');
const { client } = useAuthStore();
const {
selectedEmailIds,
@@ -57,6 +63,8 @@ export function EmailList({
batchMarkAsRead,
batchDelete,
batchMoveToMailbox,
batchMarkAsSpam,
batchUndoSpam,
loadMoreEmails,
hasMoreEmails,
isLoadingMore,
@@ -188,7 +196,7 @@ export function EmailList({
variant="ghost"
size="sm"
onClick={() => handleBatchMarkAsRead(true)}
title="Mark as read"
title={t('batch_actions.mark_read')}
disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50"
>
@@ -202,7 +210,7 @@ export function EmailList({
variant="ghost"
size="sm"
onClick={() => handleBatchMarkAsRead(false)}
title="Mark as unread"
title={t('batch_actions.mark_unread')}
disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50"
>
@@ -216,7 +224,7 @@ export function EmailList({
variant="ghost"
size="sm"
onClick={handleBatchDelete}
title="Delete"
title={t('batch_actions.delete')}
disabled={isProcessing}
className="text-red-600 dark:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50"
>
@@ -231,7 +239,7 @@ export function EmailList({
variant="ghost"
size="sm"
onClick={clearSelection}
title="Clear selection"
title={t('batch_actions.clear_selection')}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
@@ -261,13 +269,13 @@ export function EmailList({
)}
</button>
<h2 className="text-sm font-medium text-foreground">
{isLoading ? 'Loading...' : threadGroups.length > 0
? (totalEmails > threadGroups.length
? `${threadGroups.length} of ${totalEmails} conversations`
{isLoading ? t('loading') : threadGroups.length > 0
? (totalEmails !== undefined && totalEmails > threadGroups.length
? t('conversations_count', { count: threadGroups.length, total: totalEmails })
: hasMoreEmails
? `${threadGroups.length}+ conversations`
: `${threadGroups.length} conversations`)
: 'No conversations'}
? t('conversations_count_plus', { count: threadGroups.length })
: t('conversations_count_simple', { count: threadGroups.length }))
: t('no_conversations')}
</h2>
</div>
</div>
@@ -279,7 +287,7 @@ export function EmailList({
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-background/90 px-4 py-2 rounded-full shadow-sm border border-border">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Loading...</span>
<span>{t('loading')}</span>
</div>
</div>
)}
@@ -290,8 +298,8 @@ export function EmailList({
) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full py-12">
<Inbox className="w-16 h-16 mb-4 text-muted-foreground/50" />
<p className="text-base font-medium text-foreground">No emails in this mailbox</p>
<p className="text-sm mt-1 text-muted-foreground">New messages will appear here</p>
<p className="text-base font-medium text-foreground">{t('no_emails')}</p>
<p className="text-sm mt-1 text-muted-foreground">{t('no_emails_description')}</p>
</div>
) : (
<div className={cn("transition-opacity duration-200", isLoading && "opacity-50")}>
@@ -315,12 +323,12 @@ export function EmailList({
{isLoadingMore && hasMoreEmails && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Loading more emails...</span>
<span>{t('loading_more')}</span>
</div>
)}
{!hasMoreEmails && emails.length > 0 && (
<div className="text-sm text-muted-foreground border-t border-border pt-6">
No more emails to load
{t('no_more_emails')}
</div>
)}
</div>
@@ -338,6 +346,7 @@ export function EmailList({
menuRef={menuRef}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
selectedCount={selectedEmailIds.size}
// Single email actions
@@ -350,10 +359,42 @@ export function EmailList({
onArchive={() => onArchive?.(contextMenu.data!)}
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
// Batch actions
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
onBatchMarkAsSpam={async () => {
if (client) {
const emailIds = Array.from(selectedEmailIds);
try {
await batchMarkAsSpam(client, emailIds);
const { toast } = await import('sonner');
toast.success(
t('../email_viewer.spam.toast_batch', { count: emailIds.length })
);
} catch {
const { toast } = await import('sonner');
toast.error(t('../email_viewer.spam.error'));
}
}
}}
onBatchUndoSpam={async () => {
if (client) {
const emailIds = Array.from(selectedEmailIds);
try {
await batchUndoSpam(client, emailIds);
const { toast } = await import('sonner');
toast.success(
t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length })
);
} catch {
const { toast } = await import('sonner');
toast.error(t('../email_viewer.spam.error_not_spam'));
}
}
}}
/>
)}
</div>
+239 -106
View File
@@ -3,10 +3,11 @@
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email } from "@/lib/jmap/types";
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn } from "@/lib/utils";
import { getSecurityStatus } from "@/lib/email-headers";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import {
Reply,
ReplyAll,
@@ -51,6 +52,11 @@ import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { transformInlineStyles } from "@/lib/color-transform";
import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
interface EmailViewerProps {
email: Email | null;
@@ -65,9 +71,12 @@ interface EmailViewerProps {
onSetColorTag?: (emailId: string, color: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onQuickReply?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onBack?: () => void;
currentUserEmail?: string;
currentUserName?: string;
currentMailboxRole?: string;
className?: string;
}
@@ -97,17 +106,6 @@ const getFileIcon = (name?: string, type?: string) => {
return File;
};
// Color options for email tags
const colorOptions = [
{ name: "Red", value: "red", color: "bg-red-500" },
{ name: "Orange", value: "orange", color: "bg-orange-500" },
{ name: "Yellow", value: "yellow", color: "bg-yellow-500" },
{ name: "Green", value: "green", color: "bg-green-500" },
{ name: "Blue", value: "blue", color: "bg-blue-500" },
{ name: "Purple", value: "purple", color: "bg-purple-500" },
{ name: "Pink", value: "pink", color: "bg-pink-500" },
];
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
@@ -118,6 +116,42 @@ const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
return null;
};
// Helper function to format recipients with contextual display
const formatRecipients = (
recipients: Array<{ name?: string; email: string }> | undefined,
currentUserEmail: string | undefined,
t: (key: string, params?: Record<string, string | number>) => string
): string => {
if (!recipients || recipients.length === 0) return '';
// Check if the first recipient is the current user
const firstRecipient = recipients[0];
const isFirstRecipientMe = currentUserEmail &&
(firstRecipient.email.toLowerCase() === currentUserEmail.toLowerCase() ||
firstRecipient.email.toLowerCase().startsWith(currentUserEmail.toLowerCase().split('@')[0] + '+'));
// If only one recipient and it's the current user, show "me"
if (recipients.length === 1 && isFirstRecipientMe) {
return t('recipient_me');
}
// Format up to 2 recipients by name (or email if no name)
const displayRecipients = recipients.slice(0, 2).map((r, index) => {
if (index === 0 && isFirstRecipientMe) {
return t('recipient_me');
}
return r.name || r.email;
});
// If more than 2 recipients, add count
if (recipients.length > 2) {
const displayName = displayRecipients[0];
return t('recipient_and_others', { name: displayName, count: recipients.length - 1 });
}
return displayRecipients.join(', ');
};
export function EmailViewer({
email,
isLoading = false,
@@ -131,9 +165,12 @@ export function EmailViewer({
onSetColorTag,
onDownloadAttachment,
onQuickReply,
onMarkAsSpam,
onUndoSpam,
onBack,
currentUserEmail,
currentUserName,
currentMailboxRole,
className,
}: EmailViewerProps) {
const t = useTranslations('email_viewer');
@@ -143,9 +180,25 @@ export function EmailViewer({
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
// Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk';
// Color options for email tags (using translations)
const colorOptions = [
{ name: t("color_tag.red"), value: "red", color: "bg-red-500" },
{ name: t("color_tag.orange"), value: "orange", color: "bg-orange-500" },
{ name: t("color_tag.yellow"), value: "yellow", color: "bg-yellow-500" },
{ name: t("color_tag.green"), value: "green", color: "bg-green-500" },
{ name: t("color_tag.blue"), value: "blue", color: "bg-blue-500" },
{ name: t("color_tag.purple"), value: "purple", color: "bg-purple-500" },
{ name: t("color_tag.pink"), value: "pink", color: "bg-pink-500" },
];
// Tablet list visibility
const { isTablet } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities } = useAuthStore();
const theme = useThemeStore((state) => state.theme);
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [allowExternalContent, setAllowExternalContent] = useState(false);
const [hasBlockedContent, setHasBlockedContent] = useState(false);
@@ -154,6 +207,13 @@ export function EmailViewer({
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
const [showSourceModal, setShowSourceModal] = useState(false);
const currentColor = getCurrentColor(email?.keywords);
const [dismissedUnsubBanners, setDismissedUnsubBanners] = useState<Set<string>>(
() => {
if (typeof window === 'undefined') return new Set();
const saved = localStorage.getItem('dismissed-unsub-banners');
return saved ? new Set(JSON.parse(saved)) : new Set();
}
);
useEffect(() => {
// Mark as read when email is viewed
@@ -339,16 +399,8 @@ export function EmailViewer({
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Check if HTML is just a minimal wrapper around plain text
// by checking if it lacks common HTML formatting elements
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote');
const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2;
const hasBrTags = tempDiv.querySelectorAll('br').length > 0;
// Use HTML if it has rich formatting, multiple paragraphs, or explicit line breaks
useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags);
// Use safe parsing instead of innerHTML to detect rich formatting
useHtmlVersion = hasRichFormatting(htmlContent);
}
// If we should use HTML version and it exists
@@ -356,14 +408,8 @@ export function EmailViewer({
// Create a custom DOMPurify hook to handle external content
let blockedExternalContent = false;
const sanitizeConfig = {
ADD_TAGS: ['style'],
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: true,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
};
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
// Check if sender is trusted
const senderEmail = email.from?.[0]?.email?.toLowerCase();
@@ -406,6 +452,15 @@ export function EmailViewer({
blockedExternalContent = true;
}
}
// Transform inline color styles for dark mode readability
if (theme === 'dark') {
const originalStyles = htmlNode.style.cssText;
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
if (transformedStyles !== originalStyles) {
htmlNode.style.cssText = transformedStyles;
}
}
}
});
}
@@ -460,16 +515,26 @@ export function EmailViewer({
.replace(/\n/g, '<br>');
return {
html: `<div style="color: #666; font-style: italic;">${previewHtml}</div>`,
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
isHtml: false
};
}
return {
html: '<p style="color: #999;">No content available</p>',
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false
};
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted]);
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, theme]);
// Detect List-Unsubscribe header for newsletter banners
const listHeaders = useMemo(() => {
if (!email?.headers) return null;
return extractListHeaders(email.headers);
}, [email?.headers]);
const shouldShowUnsubBanner =
listHeaders?.listUnsubscribe?.preferred &&
!dismissedUnsubBanners.has(email?.messageId || '');
// Show loading skeleton while email is being fetched
if (isLoading && !email) {
@@ -574,7 +639,7 @@ export function EmailViewer({
)}
<div className="flex-1 min-w-0">
<h1 className="text-lg lg:text-2xl font-bold text-foreground tracking-tight truncate pr-2">
{email.subject || "(no subject)"}
{email.subject || t('no_subject')}
</h1>
<div className="flex items-center gap-2 lg:gap-3 mt-1.5 lg:mt-2 text-xs lg:text-sm text-muted-foreground flex-wrap lg:flex-nowrap">
<span className="flex items-center gap-1 lg:gap-1.5 whitespace-nowrap">
@@ -616,7 +681,7 @@ export function EmailViewer({
onClick={onReply}
size="sm"
className="mr-1 h-8 lg:h-9"
title="Reply"
title={t('tooltips.reply')}
>
<Reply className="w-4 h-4" />
<span className="ml-1.5 hidden lg:inline">Reply</span>
@@ -657,16 +722,39 @@ export function EmailViewer({
size="icon"
onClick={onArchive}
className="h-8 w-8 hover:bg-muted hidden lg:flex"
title="Archive"
title={t('tooltips.archive')}
>
<Archive className="w-4 h-4 text-muted-foreground" />
</Button>
{/* Spam/Not Spam Button - Desktop only, contextual based on folder */}
{(onMarkAsSpam || onUndoSpam) && (
<Button
variant="ghost"
size="icon"
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
className={cn(
"hidden h-8 w-8 lg:flex",
isInJunkFolder
? "hover:bg-green-50 dark:hover:bg-green-950/30"
: "hover:bg-red-50 dark:hover:bg-red-950/30"
)}
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
>
{isInJunkFolder ? (
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={onDelete}
className="h-8 w-8 hover:bg-muted"
title="Delete"
title={t('tooltips.delete')}
>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
@@ -768,6 +856,30 @@ export function EmailViewer({
<Printer className="w-4 h-4" />
{t('print')}
</button>
{/* Separator */}
<div className="h-px bg-border my-1" />
{/* Spam action - contextual */}
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
className={cn(
"w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2",
isInJunkFolder ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"
)}
>
{isInJunkFolder ? (
<>
<ShieldCheck className="w-4 h-4" />
{t('spam.not_spam_title')}
</>
) : (
<>
<ShieldAlert className="w-4 h-4" />
{t('spam.button_title')}
</>
)}
</button>
)}
</div>
</div>
</div>
@@ -786,40 +898,39 @@ export function EmailViewer({
/>
<div className="flex-1 min-w-0">
{/* Sender line with compact badges */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground">
{sender?.name || sender?.email || t('unknown_sender')}
</span>
{sender?.email && sender?.name && (
<span className="text-sm text-muted-foreground">
&lt;{sender.email}&gt;
</span>
)}
<EmailIdentityBadge email={email} identities={identities} />
</div>
{/* Recipient section - separate line */}
<div className="mt-2 space-y-1">
{email.to && email.to.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">To:</span>
<span className="text-muted-foreground">{t('recipient_to_prefix')}</span>
<span className="text-foreground">
{email.to.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.to.length > 2 && (
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline"
>
{t('more_count', { count: email.to.length - 2 })}
</button>
)}
{formatRecipients(email.to, currentUserEmail, t)}
</span>
{email.to.length > 2 && (
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline"
>
{t('more_count', { count: email.to.length - 2 })}
</button>
)}
</div>
)}
{(email.cc && email.cc.length > 0) && (
{email.cc && email.cc.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">CC:</span>
<span className="text-foreground">
{email.cc.map(r => r.name || r.email).join(", ")}
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
</span>
</div>
)}
@@ -1139,72 +1250,94 @@ export function EmailViewer({
className="shadow-sm w-10 h-10"
/>
<div className="flex-1 min-w-0">
{/* Mobile 2-line layout */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground">
<span className="text-sm font-semibold text-foreground">
{sender?.name || sender?.email || t('unknown_sender')}
</span>
<EmailIdentityBadge email={email} identities={identities} />
</div>
<div className="mt-1 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
{sender?.email && sender?.name && (
<span className="text-sm text-muted-foreground">
&lt;{sender.email}&gt;
</span>
<>
<span className="truncate">{sender.email}</span>
<span>·</span>
</>
)}
</div>
<div className="mt-1 space-y-0.5">
{email.to && email.to.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">To:</span>
<span className="text-foreground truncate">
{email.to.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.to.length > 2 && ` +${email.to.length - 2}`}
<>
<span> {t('recipient_to_prefix')}</span>
<span className="text-foreground">
{formatRecipients(email.to, currentUserEmail, t)}
</span>
</div>
)}
{email.cc && email.cc.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">CC:</span>
<span className="text-foreground truncate">
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
</span>
</div>
</>
)}
</div>
{/* CC line (mobile - only if present) */}
{email.cc && email.cc.length > 0 && (
<div className="mt-1 flex items-center gap-1 text-sm">
<span className="text-muted-foreground">CC:</span>
<span className="text-foreground truncate">
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
</span>
</div>
)}
</div>
</div>
</div>
{/* External Content Banner - show in 'ask' or 'block' mode */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="border-b border-border">
<div className="max-w-4xl mx-auto px-6 py-2 flex items-center justify-center gap-4">
{/* Load images button - only in 'ask' mode */}
{externalContentPolicy === 'ask' && (
<button
onClick={() => setAllowExternalContent(true)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Image className="w-3.5 h-3.5" />
{t('load_external_content')}
</button>
)}
{/* Trust sender button - in both 'ask' and 'block' modes */}
{email.from?.[0]?.email && (
<>
{externalContentPolicy === 'ask' && <span className="text-muted-foreground/50">|</span>}
<button
onClick={() => {
const senderEmail = email.from?.[0]?.email;
if (senderEmail) {
addTrustedSender(senderEmail);
setAllowExternalContent(true);
}
{/* Unified Notification Banner - External Content + Unsubscribe */}
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe)) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-4xl mx-auto px-6 py-1.5">
<div className="flex flex-col md:flex-row md:items-center md:justify-center gap-3 isolate">
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-center gap-3 flex-wrap">
{/* Load images button - only in 'ask' mode */}
{externalContentPolicy === 'ask' && (
<button
onClick={() => setAllowExternalContent(true)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
<Image className="w-3.5 h-3.5" />
{t('load_external_content')}
</button>
)}
{/* Trust sender button - in both 'ask' and 'block' modes */}
{email.from?.[0]?.email && (
<button
onClick={() => {
const senderEmail = email.from?.[0]?.email;
if (senderEmail) {
addTrustedSender(senderEmail);
setAllowExternalContent(true);
}
}}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('trust_sender')}
</button>
)}
</div>
)}
{/* Unsubscribe Controls */}
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t('trust_sender')}
</button>
</>
)}
/>
)}
</div>
</div>
</div>
)}
+5 -13
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn } from "@/lib/utils";
@@ -260,24 +261,15 @@ function EmailCard({
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote');
const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2;
const hasBrTags = tempDiv.querySelectorAll('br').length > 0;
useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags);
// Use safe parsing instead of innerHTML to detect rich formatting
useHtmlVersion = hasRichFormatting(htmlContent);
}
if (useHtmlVersion && htmlContent) {
let blockedExternalContent = false;
const sanitizeConfig = {
ADD_TAGS: ['style'],
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link', 'base'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit'],
};
// Use shared sanitization config as base (more secure)
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
if (!allowExternal) {
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
+3 -1
View File
@@ -9,6 +9,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { getThreadColorTag } from "@/lib/thread-utils";
import { ThreadEmailItem } from "./thread-email-item";
import { useTranslations } from "next-intl";
interface ThreadListItemProps {
thread: ThreadGroup;
@@ -44,6 +45,7 @@ export function ThreadListItem({
onContextMenu,
onOpenConversation,
}: ThreadListItemProps) {
const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
@@ -235,7 +237,7 @@ export function ThreadListItem({
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Loading conversation...
{t('loading')}
</div>
) : (
emailsToShow.map((email, index) => (
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useState } from 'react';
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { isValidUnsubscribeUrl } from '@/lib/validation';
interface UnsubscribeBannerProps {
listUnsubscribe: {
http?: string;
mailto?: string;
preferred?: 'http' | 'mailto';
};
senderEmail: string;
onDismiss: () => void;
}
export function UnsubscribeBanner({
listUnsubscribe,
senderEmail: _senderEmail,
onDismiss
}: UnsubscribeBannerProps) {
const t = useTranslations();
const [showConfirm, setShowConfirm] = useState(false);
const [processing, setProcessing] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState(false);
const unsubMethod = listUnsubscribe.preferred;
const unsubUrl = unsubMethod === 'http'
? listUnsubscribe.http
: listUnsubscribe.mailto;
if (!unsubUrl || !unsubMethod) {
return null;
}
const handleUnsubscribe = async () => {
if (!isValidUnsubscribeUrl(unsubUrl)) {
setError(true);
setProcessing(false);
return;
}
setProcessing(true);
try {
if (unsubMethod === 'http') {
window.open(unsubUrl, '_blank', 'noopener,noreferrer');
setSuccess(true);
setProcessing(false);
setTimeout(onDismiss, 3000);
} else {
const link = document.createElement('a');
link.href = unsubUrl;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setSuccess(true);
setProcessing(false);
setTimeout(onDismiss, 3000);
}
} catch (err) {
console.error('Unsubscribe error:', err);
setError(true);
setProcessing(false);
}
};
if (success) {
return (
<div className="flex items-center gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground">
{t(unsubMethod === 'http'
? 'email_viewer.unsubscribe_banner.success_http'
: 'email_viewer.unsubscribe_banner.success_mailto'
)}
</span>
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 flex-wrap">
<AlertCircle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{t('email_viewer.unsubscribe_banner.error')}
</span>
<button
onClick={onDismiss}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.dismiss')}
</button>
</div>
);
}
return (
<div className="flex items-center gap-2 flex-wrap">
{showConfirm ? (
<>
<span className="text-sm text-muted-foreground">
{t('email_viewer.unsubscribe_banner.confirm_title')}
</span>
<button
onClick={handleUnsubscribe}
disabled={processing}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] md:min-h-0"
>
{processing && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{t('email_viewer.unsubscribe_banner.confirm_button')}
</button>
<button
onClick={() => setShowConfirm(false)}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.cancel')}
</button>
</>
) : (
<button
onClick={() => setShowConfirm(true)}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.button')}
</button>
)}
</div>
);
}