mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-10-01 00:00:31 +00:00
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:
co-authored by
Claude Sonnet 4.5
parent
f62f5adf29
commit
da9fa4e28a
@@ -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>
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+11
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user