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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user