Files
SRCmail/components/email/email-context-menu.tsx
T
Matthieu MALVACHEandMatthieu MALVACHE 5d273e2109 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
2026-01-08 22:15:32 +01:00

321 lines
8.5 KiB
TypeScript

"use client";
import { useTranslations } from "next-intl";
import { Email, Mailbox } from "@/lib/jmap/types";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSubMenu,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import {
Reply,
ReplyAll,
Forward,
Mail,
MailOpen,
Star,
Trash2,
Archive,
FolderInput,
Palette,
X,
Inbox,
Send,
File,
Folder,
ShieldAlert,
ShieldCheck,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface Position {
x: number;
y: number;
}
interface EmailContextMenuProps {
email: Email;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
mailboxes: Mailbox[];
selectedMailbox: string;
currentMailboxRole?: string;
isMultiSelect?: boolean;
selectedCount?: number;
// Single email actions
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onMarkAsRead?: (read: boolean) => void;
onToggleStar?: () => void;
onDelete?: () => void;
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;
}
// Get mailbox icon based on role
const getMailboxIcon = (role?: string) => {
switch (role) {
case "inbox":
return Inbox;
case "sent":
return Send;
case "drafts":
return File;
case "trash":
return Trash2;
case "archive":
return Archive;
default:
return Folder;
}
};
// Get current color from email keywords
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
if (key.startsWith("$color:") && keywords[key] === true) {
return key.replace("$color:", "");
}
}
return null;
};
export function EmailContextMenu({
email,
position,
isOpen,
onClose,
menuRef,
mailboxes,
selectedMailbox,
currentMailboxRole,
isMultiSelect = false,
selectedCount = 1,
onReply,
onReplyAll,
onForward,
onMarkAsRead,
onToggleStar,
onDelete,
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(
(m) =>
m.id !== selectedMailbox &&
m.role !== "drafts" &&
!m.id.startsWith("shared-") &&
m.myRights?.mayAddItems
);
const handleAction = (action: () => void) => {
action();
onClose();
};
return (
<ContextMenu
ref={menuRef}
isOpen={isOpen}
position={position}
onClose={onClose}
>
{/* Batch header */}
{showBatchActions && (
<ContextMenuHeader>
{t("items_selected", { count: selectedCount })}
</ContextMenuHeader>
)}
{/* Single email actions - Reply, Reply All, Forward */}
{!showBatchActions && (
<>
<ContextMenuItem
icon={Reply}
label={t("reply")}
onClick={() => handleAction(onReply!)}
disabled={!onReply}
/>
<ContextMenuItem
icon={ReplyAll}
label={t("reply_all")}
onClick={() => handleAction(onReplyAll!)}
disabled={!onReplyAll}
/>
<ContextMenuItem
icon={Forward}
label={t("forward")}
onClick={() => handleAction(onForward!)}
disabled={!onForward}
/>
<ContextMenuSeparator />
</>
)}
{/* Mark as read/unread */}
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMarkAsRead?.(isUnread)
: onMarkAsRead?.(isUnread)
)
}
/>
{/* Star/Unstar - only for single email */}
{!showBatchActions && (
<ContextMenuItem
icon={Star}
label={isStarred ? t("unstar") : t("star")}
onClick={() => handleAction(onToggleStar!)}
disabled={!onToggleStar}
/>
)}
<ContextMenuSeparator />
{/* Move to submenu */}
{moveTargets.length > 0 && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
{moveTargets.map((mailbox) => {
const Icon = getMailboxIcon(mailbox.role);
return (
<ContextMenuItem
key={mailbox.id}
icon={Icon}
label={mailbox.name}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMoveToMailbox?.(mailbox.id)
: onMoveToMailbox?.(mailbox.id)
)
}
/>
);
})}
</ContextMenuSubMenu>
)}
{/* Archive */}
<ContextMenuItem
icon={Archive}
label={t("archive")}
onClick={() => handleAction(onArchive!)}
disabled={!onArchive}
/>
<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")}>
<div className="px-3 py-2 flex flex-wrap gap-1.5">
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() =>
handleAction(() => onSetColorTag?.(option.value))
}
className={cn(
"w-6 h-6 rounded-full hover:scale-110 transition-transform",
option.color,
currentColor === option.value &&
"ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
title={option.name}
/>
))}
</div>
{currentColor && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={X}
label={t("remove_color")}
onClick={() => handleAction(() => onSetColorTag?.(null))}
/>
</>
)}
</ContextMenuSubMenu>
)}
<ContextMenuSeparator />
{/* Delete */}
<ContextMenuItem
icon={Trash2}
label={t("delete")}
onClick={() =>
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
}
disabled={showBatchActions ? !onBatchDelete : !onDelete}
destructive
/>
</ContextMenu>
);
}