diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 0125f70..8214171 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -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" /> @@ -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} /> diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 3d91183..e582dcc 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -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; 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({ + {/* Spam - contextual based on folder */} + + handleAction( + showBatchActions + ? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!) + : (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!) + ) + } + disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)} + destructive={!isInJunkFolder} + /> + + + {/* Set color submenu - only for single email */} {!showBatchActions && ( diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 453ee2a..b9131d3 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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)); + } + }} /> )} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index d2afa15..03e907a 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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; + 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({ > + + {/* Spam/Not Spam Button - Desktop only, contextual based on folder */} + {(onMarkAsSpam || onUndoSpam) && ( + + )} + + {/* Separator */} +
+ {/* Spam action - contextual */} + {(onMarkAsSpam || onUndoSpam) && ( + + )}
diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts index 47ad3b4..4b7faa0 100644 --- a/hooks/use-keyboard-shortcuts.ts +++ b/hooks/use-keyboard-shortcuts.ts @@ -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" }, diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index c026a5c..578d701 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -753,6 +753,58 @@ export class JMAPClient { ]); } + /** + * Move email to Junk folder + */ + async markAsSpam(emailId: string, accountId?: string): Promise { + 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 { + 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 diff --git a/locales/en/common.json b/locales/en/common.json index 16e85ee..d807f0c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/fr/common.json b/locales/fr/common.json index 0d8b03b..560a8a3 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 9957958..0770854 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1d7690c..1779f6a 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/stores/email-store.ts b/stores/email-store.ts index f139b46..7e56183 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -58,6 +58,12 @@ interface EmailStore { batchDelete: (client: JMAPClient) => Promise; batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise; + // Spam operations + spamUndoCache: Map; + markAsSpam: (client: JMAPClient, emailId: string) => Promise; + undoSpam: (client: JMAPClient, emailId: string) => Promise; + batchMarkAsSpam: (client: JMAPClient, emailIds: string[]) => Promise; + // Push notification handlers setPushConnected: (connected: boolean) => void; handleStateChange: (change: StateChange, client: JMAPClient) => Promise; @@ -99,6 +105,9 @@ export const useEmailStore = create((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((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 });