feat(email): add spam reporting with bidirectional handling

Implement comprehensive spam management allowing users to report spam emails and restore false positives. Emails are moved to/from the Junk folder with toast notifications and undo support.

Key features:
- Mark as spam: moves email to Junk folder with 5-second undo
- Mark as not spam: restores email from Junk to inbox
- Batch spam operations via context menu
- Smart toggle based on current folder (Junk vs other folders)
- Keyboard shortcut (Shift+!) for quick spam reporting
- Full i18n support (EN/FR)
- Shared mailbox compatibility

UX improvements:
- Toast notifications with undo action using sonner library
- Auto-deselect email after spam action
- Visual distinction with red destructive styling
- Desktop button + context menu + keyboard shortcuts

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Matthieu MALVACHE
2026-01-08 17:55:24 +01:00
co-authored by Claude Sonnet 4.5
parent f62f5adf29
commit da9fa4e28a
11 changed files with 388 additions and 2 deletions
+74
View File
@@ -75,6 +75,8 @@ export default function Home() {
setPushConnected,
handleStateChange,
clearNewEmailNotification,
markAsSpam,
undoSpam,
} = useEmailStore();
// Play notification sound for new emails
@@ -157,6 +159,18 @@ export default function Home() {
await markAsRead(client, selectedEmail.id, true);
}
},
onToggleSpam: () => {
if (selectedEmail) {
// Check if we're in junk folder
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInJunk = currentMailbox?.role === 'junk';
if (isInJunk) {
handleUndoSpam();
} else {
handleMarkAsSpam();
}
}
},
onCompose: () => {
setComposerMode('compose');
setShowComposer(true);
@@ -445,6 +459,55 @@ export default function Home() {
}
};
const handleMarkAsSpam = async () => {
if (!client || !selectedEmail) return;
const emailId = selectedEmail.id;
try {
await markAsSpam(client, emailId);
const toastInstance = (await import('sonner')).toast;
toastInstance.success(t('email_viewer.spam.toast_success'), {
action: {
label: t('email_viewer.spam.toast_undo'),
onClick: async () => {
try {
await undoSpam(client, emailId);
toastInstance.success(t('notifications.email_moved'));
} catch (_error) {
console.error("Failed to undo spam:", _error);
toastInstance.error(t('email_viewer.spam.error'));
}
},
},
duration: 5000,
});
} catch (_error) {
console.error("Failed to mark as spam:", _error);
const toastInstance = (await import('sonner')).toast;
toastInstance.error(t('email_viewer.spam.error'));
}
};
const handleUndoSpam = async () => {
if (!client || !selectedEmail) return;
try {
await undoSpam(client, selectedEmail.id);
const toastInstance = (await import('sonner')).toast;
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
// Deselect email after moving it out of junk
selectEmail(null);
} catch (_error) {
console.error("Failed to restore email:", _error);
const toastInstance = (await import('sonner')).toast;
toastInstance.error(t('email_viewer.spam.error_not_spam'));
}
};
const handleSetColorTag = async (emailId: string, color: string | null) => {
if (!client) return;
@@ -769,6 +832,14 @@ export default function Home() {
await moveToMailbox(client, emailId, mailboxId);
}
}}
onMarkAsSpam={async (email) => {
selectEmail(email);
await handleMarkAsSpam();
}}
onUndoSpam={async (email) => {
selectEmail(email);
await handleUndoSpam();
}}
className="flex-1"
/>
</ErrorBoundary>
@@ -823,6 +894,8 @@ export default function Home() {
onArchive={handleArchive}
onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag}
onMarkAsSpam={handleMarkAsSpam}
onUndoSpam={handleUndoSpam}
onMarkAsRead={async (emailId, read) => {
if (client) {
await markAsRead(client, emailId, read);
@@ -836,6 +909,7 @@ export default function Home() {
}}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
className={isMobile ? "flex-1" : undefined}
/>
</ErrorBoundary>
+30
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,10 +56,14 @@ 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;
}
// Get mailbox icon based on role
@@ -96,6 +103,7 @@ export function EmailContextMenu({
menuRef,
mailboxes,
selectedMailbox,
currentMailboxRole,
isMultiSelect = false,
selectedCount = 1,
onReply,
@@ -107,9 +115,13 @@ 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");
@@ -117,6 +129,7 @@ export function EmailContextMenu({
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 = [
@@ -240,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")}>
+20
View File
@@ -31,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({
@@ -48,6 +50,8 @@ export function EmailList({
onDelete,
onArchive,
onSetColorTag,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
}: EmailListProps) {
const t = useTranslations('email_list');
@@ -59,6 +63,7 @@ export function EmailList({
batchMarkAsRead,
batchDelete,
batchMoveToMailbox,
batchMarkAsSpam,
loadMoreEmails,
hasMoreEmails,
isLoadingMore,
@@ -340,6 +345,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
@@ -352,10 +358,24 @@ 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={() => {
if (client) {
const emailIds = Array.from(selectedEmailIds);
batchMarkAsSpam(client, emailIds);
}
}}
onBatchUndoSpam={() => {
if (client && onUndoSpam) {
const selectedEmails = emails.filter(e => selectedEmailIds.has(e.id));
selectedEmails.forEach(email => onUndoSpam(email));
}
}}
/>
)}
</div>
+56
View File
@@ -68,9 +68,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;
}
@@ -159,9 +162,12 @@ export function EmailViewer({
onSetColorTag,
onDownloadAttachment,
onQuickReply,
onMarkAsSpam,
onUndoSpam,
onBack,
currentUserEmail,
currentUserName,
currentMailboxRole,
className,
}: EmailViewerProps) {
const t = useTranslations('email_viewer');
@@ -171,6 +177,9 @@ 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" },
@@ -687,6 +696,29 @@ export function EmailViewer({
>
<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"
@@ -794,6 +826,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>
+9
View File
@@ -19,6 +19,7 @@ export interface KeyboardShortcutHandlers {
onDelete?: () => void;
onMarkAsUnread?: () => void;
onMarkAsRead?: () => void;
onToggleSpam?: () => void;
// Global actions
onCompose?: () => void;
@@ -180,6 +181,13 @@ export function useKeyboardShortcuts({
}
break;
case "!":
if (selectedEmailId) {
event.preventDefault();
h.onToggleSpam?.();
}
break;
// Global actions
case "c":
event.preventDefault();
@@ -264,6 +272,7 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "# / Del", description: "shortcuts.actions.delete" },
{ key: "u", description: "shortcuts.actions.mark_unread" },
{ key: "Shift + I", description: "shortcuts.actions.mark_read" },
{ key: "!", description: "shortcuts.actions.toggle_spam" },
],
global: [
{ key: "c", description: "shortcuts.global.compose" },
+52
View File
@@ -753,6 +753,58 @@ export class JMAPClient {
]);
}
/**
* Move email to Junk folder
*/
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
const mailboxes = await this.getMailboxes();
const junkMailbox = mailboxes.find(m => {
if (accountId) {
return m.role === 'junk' && m.accountId === accountId;
}
return m.role === 'junk' && !m.isShared;
});
if (!junkMailbox) {
throw new Error('Junk mailbox not found');
}
const mailboxId = accountId && junkMailbox.originalId
? junkMailbox.originalId
: junkMailbox.id;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [mailboxId]: true },
},
},
}, "0"],
]);
}
/**
* Undo spam - move email back from Junk to original mailbox
*/
async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [originalMailboxId]: true },
},
},
}, "0"],
]);
}
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try {
// Use provided accountId or fallback to primary account
+15 -1
View File
@@ -195,6 +195,17 @@
"reply": "Reply",
"archive": "Archive",
"delete": "Delete"
},
"spam": {
"button_title": "Report spam",
"not_spam_title": "Mark as legitimate",
"toast_success": "Moved to Junk",
"toast_batch": "{count} emails moved to Junk",
"toast_undo": "Undo",
"toast_not_spam_success": "Moved to Inbox",
"toast_not_spam_batch": "{count} emails moved to Inbox",
"error": "Failed to report spam",
"error_not_spam": "Failed to restore email"
}
},
"email_composer": {
@@ -582,6 +593,8 @@
"move_to": "Move to...",
"archive": "Archive",
"delete": "Delete",
"mark_as_spam": "Report spam",
"not_spam": "Not spam",
"color_tag": "Color Tag",
"remove_color": "Remove Color",
"items_selected": "{{count}} emails selected"
@@ -609,7 +622,8 @@
"archive": "Archive",
"delete": "Delete",
"mark_unread": "Mark as unread",
"mark_read": "Mark as read"
"mark_read": "Mark as read",
"toggle_spam": "Report spam / Not spam"
},
"global": {
"compose": "Compose new email",
+15 -1
View File
@@ -195,6 +195,17 @@
"reply": "Répondre",
"archive": "Archiver",
"delete": "Supprimer"
},
"spam": {
"button_title": "Signaler comme spam",
"not_spam_title": "Marquer comme légitime",
"toast_success": "Déplacé vers Indésirables",
"toast_batch": "{count} e-mails déplacés vers Indésirables",
"toast_undo": "Annuler",
"toast_not_spam_success": "Déplacé vers Boîte de réception",
"toast_not_spam_batch": "{count} e-mails déplacés vers Boîte de réception",
"error": "Échec du signalement spam",
"error_not_spam": "Échec de la restauration"
}
},
"email_composer": {
@@ -582,6 +593,8 @@
"move_to": "Déplacer vers...",
"archive": "Archiver",
"delete": "Supprimer",
"mark_as_spam": "Signaler comme spam",
"not_spam": "Pas un spam",
"color_tag": "Étiquette de couleur",
"remove_color": "Supprimer la couleur",
"items_selected": "{{count}} emails sélectionnés"
@@ -609,7 +622,8 @@
"archive": "Archiver",
"delete": "Supprimer",
"mark_unread": "Marquer comme non lu",
"mark_read": "Marquer comme lu"
"mark_read": "Marquer comme lu",
"toggle_spam": "Signaler / Pas un spam"
},
"global": {
"compose": "Composer un email",
+11
View File
@@ -20,6 +20,7 @@
"next-intl": "^4.5.8",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.9"
},
@@ -9158,6 +9159,16 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+1
View File
@@ -47,6 +47,7 @@
"next-intl": "^4.5.8",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.9"
},
+105
View File
@@ -58,6 +58,12 @@ interface EmailStore {
batchDelete: (client: JMAPClient) => Promise<void>;
batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
// Spam operations
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
markAsSpam: (client: JMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: JMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
// Push notification handlers
setPushConnected: (connected: boolean) => void;
handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>;
@@ -99,6 +105,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
threadEmailsCache: new Map(),
isLoadingThread: null,
// Spam undo cache
spamUndoCache: new Map(),
setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }),
selectEmail: (email) => set({ selectedEmail: email }),
@@ -762,6 +771,102 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
// Spam operations
markAsSpam: async (client, emailId) => {
const { selectedMailbox, mailboxes, emails } = get();
const email = emails.find(e => e.id === emailId);
if (!email) return;
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return;
get().spamUndoCache.set(emailId, {
emailId,
originalMailboxId: currentMailbox.originalId || currentMailbox.id,
accountId: currentMailbox.accountId,
});
try {
await client.markAsSpam(emailId, currentMailbox.accountId);
set(state => ({
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
}));
const currentIndex = emails.findIndex(e => e.id === emailId);
if (currentIndex >= 0 && currentIndex < emails.length - 1) {
set({ selectedEmail: emails[currentIndex + 1] });
}
} catch (error) {
console.error('Failed to mark as spam:', error);
throw error;
}
},
undoSpam: async (client, emailId) => {
const { mailboxes, selectedMailbox } = get();
// Try cache first (preserves exact original mailbox for toast undo)
const cachedData = get().spamUndoCache.get(emailId);
let targetMailboxId: string;
let accountId: string | undefined;
if (cachedData) {
// Use cached original mailbox (more accurate for immediate undo)
targetMailboxId = cachedData.originalMailboxId;
accountId = cachedData.accountId;
get().spamUndoCache.delete(emailId);
} else {
// Fall back to finding Inbox (generic "not spam" button/menu)
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
accountId = currentMailbox?.accountId;
// Find inbox in same account
const inboxMailbox = mailboxes.find(m =>
m.role === 'inbox' &&
(accountId ? m.accountId === accountId : !m.accountId)
);
if (!inboxMailbox) {
throw new Error('Inbox not found');
}
targetMailboxId = inboxMailbox.id;
}
try {
await client.undoSpam(emailId, targetMailboxId, accountId);
await get().fetchEmails(client, selectedMailbox);
} catch (error) {
console.error('Failed to restore email:', error);
throw error;
}
},
batchMarkAsSpam: async (client, emailIds) => {
const { selectedMailbox, mailboxes } = get();
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return;
try {
for (const emailId of emailIds) {
await client.markAsSpam(emailId, currentMailbox.accountId);
}
set(state => ({
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
}));
} catch (error) {
console.error('Failed to batch mark as spam:', error);
throw error;
}
},
// Push notification handlers
setPushConnected: (connected) => {
set({ isPushConnected: connected });