diff --git a/components/email/__tests__/permanent-delete-feedback.test.tsx b/components/email/__tests__/permanent-delete-feedback.test.tsx index 384f2eb2c..c396bb582 100644 --- a/components/email/__tests__/permanent-delete-feedback.test.tsx +++ b/components/email/__tests__/permanent-delete-feedback.test.tsx @@ -62,8 +62,9 @@ async function clickDelete(surface: string) { fireEvent.contextMenu(await screen.findByText('ok')); fireEvent.click(await screen.findByTestId('ctx-delete')); } else { fireEvent.click(surface === 'mountain' ? screen.getByLabelText('delete') : screen.getByTitle('batch_actions.delete')); - fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'permanent_delete' })); } + // A permanent delete asks first, from every place it is offered. + fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'permanent_delete' })); } for (const surface of ['mountain', 'default', 'context']) describe(surface + ' permanent bulk-delete feedback', () => { diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index af703af36..cdca2657a 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -15,7 +15,7 @@ import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, Calenda import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; -import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, type ListRow } from "@/stores/email-store"; +import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, restoreBatchesForSelection, isAggregateListView, type ListRow } from "@/stores/email-store"; import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder"; import { useShallow } from "zustand/react/shallow"; import { useAuthStore } from "@/stores/auth-store"; @@ -29,7 +29,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useTranslations } from "next-intl"; import { toast } from "@/stores/toast-store"; -import { runBatchEmailAction } from "@/lib/email-action-toast"; +import { runBatchEmailAction, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; import { useVirtualizer } from "@tanstack/react-virtual"; import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { SearchChips } from "@/components/search/search-chips"; @@ -168,6 +168,7 @@ export function EmailList({ const tSpam = useTranslations('email_viewer.spam'); const tNotifications = useTranslations('notifications'); const tViewer = useTranslations('email_viewer'); + const undo = undoLabels(tNotifications); const { client } = useAuthStore(useShallow((state) => ({ client: state.client }))); const { selectedEmailKeys, @@ -605,7 +606,10 @@ export function EmailList({ setIsProcessing(true); try { const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id); - if (await batchUndoSpam(client, emailIds)) toast.success(tSpam('toast_not_spam_batch', { count: emailIds.length })); + const restore = restoreBatchesForSelection(client); + if (await batchUndoSpam(client, emailIds)) { + toastWithUndo(tSpam('toast_not_spam_batch', { count: emailIds.length }), undoMoveAction(client, restore, undo)); + } } catch { toast.error(tSpam('error_not_spam')); } finally { @@ -613,6 +617,63 @@ export function EmailList({ } }; + const handleBatchMarkAsSpam = async () => { + if (!client || isProcessing) return; + setIsProcessing(true); + try { + const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id); + const restore = restoreBatchesForSelection(client); + if (await batchMarkAsSpam(client, emailIds)) { + toastWithUndo(tSpam('toast_batch', { count: emailIds.length }), undoMoveAction(client, restore, undo)); + } + } catch { + toast.error(tSpam('error')); + } finally { + setTimeout(() => setIsProcessing(false), 500); + } + }; + + const handleBatchArchive = async () => { + if (!client || isProcessing) return; + setIsProcessing(true); + const count = selectedEmailKeys.size; + const restore = restoreBatchesForSelection(client); + try { + await runBatchEmailAction(() => batchArchive(client), { + success: tNotifications('emails_archived', { count }), + error: tNotifications('error_archiving'), + describeError: (error) => error instanceof ArchiveMailboxNotFoundError + ? tViewer('archive_mailbox_not_found') + : undefined, + undo: undoMoveAction(client, restore, undo), + }); + } finally { + setTimeout(() => setIsProcessing(false), 500); + } + }; + + const handleBatchMoveToMailbox = async (mailboxId: string) => { + if (!client || isProcessing) return; + setIsProcessing(true); + const count = selectedEmailKeys.size; + // A move to another account's folder copies the messages there, which + // Undo can't take back. + const destination = mailboxes.find(m => m.id === mailboxId); + const source = mailboxes.find(m => m.id === selectedMailbox); + const crossAccount = isAggregateListView() + || (!!destination?.accountId && !!source?.accountId && destination.accountId !== source.accountId); + const restore = crossAccount ? null : restoreBatchesForSelection(client); + try { + await runBatchEmailAction(() => batchMoveToMailbox(client, mailboxId), { + success: tNotifications('emails_moved', { count }), + error: tNotifications('move_failed'), + undo: undoMoveAction(client, restore, undo), + }); + } finally { + setTimeout(() => setIsProcessing(false), 500); + } + }; + const handleBatchDelete = async () => { if (!client || isProcessing) return; @@ -637,10 +698,12 @@ export function EmailList({ setIsProcessing(true); const count = selectedEmailKeys.size; + const restore = isInTrash ? null : restoreBatchesForSelection(client); try { await runBatchEmailAction(() => batchDelete(client, isInTrash), { success: tNotifications('emails_deleted', { count }), error: tNotifications('error_deleting'), + undo: undoMoveAction(client, restore, undo), }); } finally { setTimeout(() => setIsProcessing(false), 500); @@ -1171,57 +1234,15 @@ export function EmailList({ batchTagIds={selectionTags.all} batchPartialTagIds={selectionTags.some} onBatchToggleTag={handleBatchToggleTag} - onBatchDelete={async () => { - if (!client) return; - const count = selectedEmailKeys.size; - await runBatchEmailAction(() => batchDelete(client), { - success: tNotifications('emails_deleted', { count }), - error: tNotifications('error_deleting'), - }); - }} - onBatchArchive={async () => { - if (!client) return; - const count = selectedEmailKeys.size; - await runBatchEmailAction(() => batchArchive(client), { - success: tNotifications('emails_archived', { count }), - error: tNotifications('error_archiving'), - describeError: (error) => error instanceof ArchiveMailboxNotFoundError - ? tViewer('archive_mailbox_not_found') - : undefined, - }); - }} + onBatchDelete={() => void handleBatchDelete()} + onBatchArchive={() => void handleBatchArchive()} batchEmails={selectedEmailsForRemoval} onBatchRemoveFromFolder={() => void handleBatchRemoveFromFolder()} - onBatchMoveToMailbox={async (mailboxId) => { - if (!client) return; - const count = selectedEmailKeys.size; - await runBatchEmailAction(() => batchMoveToMailbox(client, mailboxId), { - success: tNotifications('emails_moved', { count }), - error: tNotifications('move_failed'), - }); - }} - onBatchMarkAsSpam={async () => { - if (client) { - const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id); - try { - if (await batchMarkAsSpam(client, emailIds)) toast.success( - tSpam('toast_batch', { count: emailIds.length }) - ); - } catch { - toast.error(tSpam('error')); - } - } - }} + onBatchMoveToMailbox={(mailboxId) => void handleBatchMoveToMailbox(mailboxId)} + onBatchMarkAsSpam={() => void handleBatchMarkAsSpam()} onBatchUndoSpam={async () => { if (client) { - const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id); - try { - if (await batchUndoSpam(client, emailIds)) toast.success( - tSpam('toast_not_spam_batch', { count: emailIds.length }) - ); - } catch { - toast.error(tSpam('error_not_spam')); - } + await handleBatchUndoSpam(); } }} /> diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index 77cfc8b98..f6a1b8e95 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -22,9 +22,9 @@ import { import { useTranslations } from "next-intl"; import { useAuthStore } from "@/stores/auth-store"; import { useShallow } from "zustand/react/shallow"; -import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; +import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, restoreBatchesForSelection } from "@/stores/email-store"; import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder"; -import { runBatchEmailAction } from "@/lib/email-action-toast"; +import { runBatchEmailAction, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; import { toast } from "@/stores/toast-store"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; @@ -117,6 +117,7 @@ export function MvListToolbar({ const tViewer = useTranslations("email_viewer"); const tNotifications = useTranslations("notifications"); const tSpam = useTranslations("email_viewer.spam"); + const undo = undoLabels(tNotifications); const client = useAuthStore((s) => s.client); const { @@ -232,7 +233,7 @@ export function MvListToolbar({ // rejected promise or a store error turns into an error toast instead of // an unhandled rejection with nothing on screen. const run = async ( - action: () => Promise, + action: () => Promise, messages?: Parameters[1], ) => { if (!client || isProcessing) return; @@ -279,9 +280,11 @@ export function MvListToolbar({ }); if (!confirmed) return; } - await runBatchEmailAction(() => batchDelete(client, isInTrash), { + const restore = permanent ? null : restoreBatchesForSelection(client); + await runBatchEmailAction(() => batchDelete(client, permanent), { success: tNotifications("emails_deleted", { count: selectionCount }), error: tNotifications("error_deleting"), + undo: undoMoveAction(client, restore, undo), }); }); @@ -360,11 +363,11 @@ export function MvListToolbar({ const handleMove = (mailboxId: string) => { setMoveOpen(false); const count = selectionCount; - void run(async () => { - if (client) await batchMoveToMailbox(client, mailboxId); - }, { + const restore = client ? restoreBatchesForSelection(client) : null; + void run(async () => (client ? batchMoveToMailbox(client, mailboxId) : false), { success: tNotifications("emails_moved", { count }), error: tNotifications("move_failed"), + undo: undoMoveAction(client, restore, undo), }); }; @@ -381,11 +384,12 @@ export function MvListToolbar({ run(async () => { if (!client) return; const ids = resolveSelectedEmails(useEmailStore.getState()).map((email) => email.id); + const restore = restoreBatchesForSelection(client); try { if (isInJunk) { - if (await batchUndoSpam(client, ids)) toast.success(tSpam("toast_not_spam_batch", { count: ids.length })); + if (await batchUndoSpam(client, ids)) toastWithUndo(tSpam("toast_not_spam_batch", { count: ids.length }), undoMoveAction(client, restore, undo)); } else { - if (await batchMarkAsSpam(client, ids)) toast.success(tSpam("toast_batch", { count: ids.length })); + if (await batchMarkAsSpam(client, ids)) toastWithUndo(tSpam("toast_batch", { count: ids.length }), undoMoveAction(client, restore, undo)); } } catch { toast.error(isInJunk ? tSpam("error_not_spam") : tSpam("error")); @@ -508,12 +512,13 @@ export function MvListToolbar({ run(async () => { if (client) await batchArchive(client); }, { + onClick={() => run(async () => (client ? batchArchive(client) : false), { success: tNotifications("emails_archived", { count: selectionCount }), error: tNotifications("error_archiving"), describeError: (error) => error instanceof ArchiveMailboxNotFoundError ? tViewer("archive_mailbox_not_found") : undefined, + undo: undoMoveAction(client, client ? restoreBatchesForSelection(client) : null, undo), })} disabled={isProcessing} busy={isProcessing} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index d9019376e..c4dfa600c 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -72,6 +72,7 @@ import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store" import { usePolicyStore } from "@/stores/policy-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; +import { toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; import { debug } from "@/lib/debug"; import { AccountSwitcher } from "./account-switcher"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; @@ -634,18 +635,12 @@ function MailboxTreeItem({ const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({ mailbox: node, accountId: dragAccountId ?? undefined, - onSuccess: (count, mailboxName) => { - if (count === 1) { - toast.success( - tNotifications('email_moved'), - tNotifications('moved_to_mailbox', { mailbox: mailboxName }) - ); - } else { - toast.success( - tNotifications('emails_moved', { count }), - tNotifications('moved_to_mailbox', { mailbox: mailboxName }) - ); - } + onSuccess: (count, mailboxName, restore) => { + toastWithUndo( + count === 1 ? tNotifications('email_moved') : tNotifications('emails_moved', { count }), + undoMoveAction(useAuthStore.getState().client, restore, undoLabels(tNotifications)), + tNotifications('moved_to_mailbox', { mailbox: mailboxName }), + ); }, onError: () => { toast.error(tNotifications('move_failed'), tNotifications('move_error')); diff --git a/components/mail/__tests__/mail-app-delete-action.test.tsx b/components/mail/__tests__/mail-app-delete-action.test.tsx index 86dc90cd0..5ba3effd3 100644 --- a/components/mail/__tests__/mail-app-delete-action.test.tsx +++ b/components/mail/__tests__/mail-app-delete-action.test.tsx @@ -136,3 +136,27 @@ describe('Delete on a selection', () => { expect(bulkDeleteIsPermanent({ role: 'inbox' })).toBe(false); }); }); + +describe('Undo after a delete to Trash', () => { + it('puts the message back where it was, unread again', async () => { + useSettingsStore.setState({ deleteAction: 'trash-and-read' } as never); + client.restoreEmails = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(await screen.findByText('stub delete', {}, { timeout: 5000 })); + await waitFor(() => expect(useToastStore.getState().toasts.some(t => t.title === 'notifications.email_deleted')).toBe(true)); + const deleted = useToastStore.getState().toasts.find(t => t.title === 'notifications.email_deleted')!; + expect(deleted.action?.label).toBe('undo'); + deleted.action!.onClick(); + await waitFor(() => expect(client.restoreEmails).toHaveBeenCalled()); + expect(client.restoreEmails).toHaveBeenCalledWith([{ id: 'm1', mailboxIds: { inbox: true }, seen: false }], undefined); + }); + + it('is not offered for a permanent delete', async () => { + useSettingsStore.setState({ deleteAction: 'permanent' } as never); + render(); + fireEvent.click(await screen.findByText('stub delete', {}, { timeout: 5000 })); + fireEvent.click(await screen.findByText('email_list.permanent_delete', { selector: 'button' })); + await waitFor(() => expect(useToastStore.getState().toasts.some(t => t.title === 'notifications.email_deleted')).toBe(true)); + expect(useToastStore.getState().toasts.find(t => t.title === 'notifications.email_deleted')!.action).toBeUndefined(); + }); +}); diff --git a/components/mail/__tests__/mail-app-source-routing.test.tsx b/components/mail/__tests__/mail-app-source-routing.test.tsx index 0d8d49e3e..40dd4d30e 100644 --- a/components/mail/__tests__/mail-app-source-routing.test.tsx +++ b/components/mail/__tests__/mail-app-source-routing.test.tsx @@ -185,7 +185,7 @@ describe('the open message of a group account in a tag view', () => { render(); fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 })); await waitFor(() => expect(moveThreadToMailbox).toHaveBeenCalled()); - expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:k', 'login-a/ce:eaaaaab'); + expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:k', 'login-a/ce:eaaaaab', expect.any(Function)); }); it('is deleted into the group\'s Deleted Items, not the own one', async () => { @@ -215,7 +215,7 @@ describe('archiving a group message by month', () => { fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 })); await waitFor(() => expect(moveThreadToMailbox).toHaveBeenCalled()); expect(client.createMailbox).not.toHaveBeenCalled(); - expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'login-a/ce:eaaaaab'); + expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'login-a/ce:eaaaaab', expect.any(Function)); }); it('reuses them with the group\'s Inbox open', async () => { @@ -225,7 +225,7 @@ describe('archiving a group message by month', () => { fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 })); await waitFor(() => expect(moveThreadToMailbox).toHaveBeenCalled()); expect(client.createMailbox).not.toHaveBeenCalled(); - expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'eaaaaab'); + expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'eaaaaab', expect.any(Function)); }); // The own account has a folder with the server id of the group's Archive, @@ -242,7 +242,7 @@ describe('archiving a group message by month', () => { render(); fireEvent.click(await screen.findByText('stub archive', {}, { timeout: 5000 })); await waitFor(() => expect(moveThreadToMailbox).toHaveBeenCalled()); - expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'eaaaaab'); + expect(moveThreadToMailbox).toHaveBeenCalledWith(client, 'eaaaaab', 'ce:m1', 'eaaaaab', expect.any(Function)); }); it('creates a missing year folder in the group account with the group\'s Inbox open', async () => { diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 785268a94..bcca3d9d8 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -37,7 +37,7 @@ import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; import { messagePriorityFromHeaders, type MessagePriority } from "@/lib/message-priority"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; +import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder, restoreBatchesFor, restoreBatchesForSelection, type EmailRestoreBatch } from "@/stores/email-store"; import { findDraftIdentityIdForLogin, listRowSource, resolveDraftSource, type DraftSource } from "@/lib/draft-source"; import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder"; import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; @@ -46,7 +46,7 @@ import { cancelFailureToast, partlyRefusedWarning, rescheduleFailureToast } from import { formatRejectedRecipients, RescheduleRestoredError } from "@/lib/jmap/client"; import { subscribePushStateChanges } from "@/lib/push-events"; import { createMailPushListener } from "@/lib/mail-push-listener"; -import { runBatchEmailAction } from "@/lib/email-action-toast"; +import { runBatchEmailAction, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog"; import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster"; import { CalendarEventNotificationToaster } from "@/components/layout/calendar-event-notification-toaster"; @@ -214,6 +214,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Static Lite build: the route params are empty, read the link from the URL. const linkSegments = useLiteLinkSegments('mail', routeSegments); const t = useTranslations(); + const tNotifications = useTranslations('notifications'); + // The toasts' Undo after a delete to Trash, archive, move or spam report. + const undo = undoLabels(tNotifications); const tCommon = useTranslations('common'); const appName = productName(useConfig().appName); const tQuote = useTranslations('quote_header'); @@ -936,8 +939,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (isScheduledView) return; if (shortcutsTargetSelection && client) { const count = selectedEmailKeys.size; + const restore = restoreBatchesForSelection(client); await runBatchEmailAction(() => batchArchive(client), { success: t('notifications.emails_archived', { count }), + undo: undoMoveAction(client, restore, undo), error: t('notifications.error_archiving'), describeError: (error) => error instanceof ArchiveMailboxNotFoundError ? t('email_viewer.archive_mailbox_not_found') @@ -968,9 +973,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (!confirmed) return; } const count = selectedEmailKeys.size; + const restore = permanent ? null : restoreBatchesForSelection(client); await runBatchEmailAction(() => batchDelete(client, permanent), { success: t('notifications.emails_deleted', { count }), error: t('notifications.error_deleting'), + undo: undoMoveAction(client, restore, undo), }); } else if (selectedEmail) { handleDelete(); @@ -1009,11 +1016,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // The spam actions take bare ids of the viewed account: resolve the // selection keys through the loaded rows and thread members. const ids = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id); + const restore = restoreBatchesForSelection(client); try { if (isInJunk) { - if (await batchUndoSpam(client, ids)) toast.success(t('email_viewer.spam.toast_not_spam_batch', { count: ids.length })); + if (await batchUndoSpam(client, ids)) toastWithUndo(t('email_viewer.spam.toast_not_spam_batch', { count: ids.length }), undoMoveAction(client, restore, undo)); } else { - if (await batchMarkAsSpam(client, ids)) toast.success(t('email_viewer.spam.toast_batch', { count: ids.length })); + if (await batchMarkAsSpam(client, ids)) toastWithUndo(t('email_viewer.spam.toast_batch', { count: ids.length }), undoMoveAction(client, restore, undo)); } } catch (error) { console.error("Failed to batch toggle spam:", error); @@ -2809,7 +2817,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const isInTrash = currentMailbox?.role === 'trash'; // Trash, Junk with "permanently delete junk", or the "Delete permanently" // setting: the mail is destroyed, so ask first, as the batch delete does. + // A move to Trash gets Undo instead. const permanent = bulkDeleteIsPermanent(currentMailbox); + const restore = permanent ? null : restoreBatchesFor([emailToDelete], client); if (permanent) { const confirmed = await confirmDialog({ @@ -2826,7 +2836,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // it. In Trash it always destroys. (#281 for the account routing) try { // false: a plugin stopped it and said why, so no "deleted" here. - if (await deleteEmail(client, emailToDelete.id, isInTrash, emailKeyFor(emailToDelete))) toast.success(t('notifications.email_deleted')); + if (await deleteEmail(client, emailToDelete.id, isInTrash, emailKeyFor(emailToDelete))) { + toastWithUndo(t('notifications.email_deleted'), undoMoveAction(client, restore, undo)); + } } catch (error) { console.error("Failed to delete email:", error); toast.error(t('notifications.error_deleting'), error instanceof Error ? error.message : undefined); @@ -2880,8 +2892,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (!archiveMailbox) return; // a plugin refused the Archive folder let archived: boolean; + let restore: EmailRestoreBatch[] | null = null; + const keepRestore = (batches: EmailRestoreBatch[]) => { restore = batches; }; if (archiveMode === 'single') { - archived = await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id, emailKeyFor(emailToArchive)); + archived = await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id, emailKeyFor(emailToArchive), keepRestore); } else { const emailDate = new Date(emailToArchive.receivedAt); const year = emailDate.getFullYear().toString(); @@ -2906,7 +2920,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } if (archiveMode === 'year') { - archived = await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id, emailKeyFor(emailToArchive)); + archived = await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id, emailKeyFor(emailToArchive), keepRestore); } else { const yearId = yearMailbox.originalId || yearMailbox.id; const afterYear = readMailboxes(); @@ -2916,7 +2930,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (!monthMailbox) return; // a plugin refused the folder await refreshMailboxes(); } - archived = await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id, emailKeyFor(emailToArchive)); + archived = await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id, emailKeyFor(emailToArchive), keepRestore); } } if (!archived) return; @@ -2928,7 +2942,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setConversationEmails([]); } - toast.success(t('notifications.email_archived')); + toastWithUndo(t('notifications.email_archived'), undoMoveAction(client, restore, undo)); void refreshMailboxes(); } catch (error) { console.error("Failed to archive email:", error); @@ -2951,10 +2965,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const destinationList = [mailboxes, ...Object.values(accountMailboxes)] .find(list => list.some(m => m.id === mailboxId)); const destination = destinationList?.find(m => m.id === mailboxId); + // Undo for a move within the message's own account; a move to another + // account copies the message there, which Undo can't take back. + const email = useEmailStore.getState().emails.find(e => (emailKey ? emailKeyFor(e) === emailKey : e.id === emailId)); + const sourceFolder = [mailboxes, ...Object.values(accountMailboxes)].flat().find(m => m.id === selectedMailbox); + const sourceAccount = email?.sourceAccountId ?? sourceFolder?.accountId; + const crossAccount = !!destination?.accountId && !!sourceAccount && destination.accountId !== sourceAccount; + const restore = email && !crossAccount ? restoreBatchesFor([email], client) : null; try { if (!(await moveToMailboxCrossAware(client, emailId, mailboxId, emailKey))) return; - toast.success( + toastWithUndo( t('notifications.email_moved'), + undoMoveAction(client, restore, undo), destination && destinationList ? t('notifications.moved_to_mailbox', { mailbox: getMailboxPath(destination, destinationList) }) : undefined, diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 8085bfb3b..878714344 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -2,7 +2,7 @@ import { useCallback, useState, DragEvent } from "react"; import { Mailbox, Email } from "@/lib/jmap/types"; -import { useEmailStore, isAggregateListView } from "@/stores/email-store"; +import { useEmailStore, isAggregateListView, restoreBatchesFor, type EmailRestoreBatch } from "@/stores/email-store"; import { useShallow } from "zustand/react/shallow"; import { emailKeyFor } from "@/lib/thread-utils"; import { useAuthStore } from "@/stores/auth-store"; @@ -35,8 +35,10 @@ interface UseMailboxDropOptions { */ accountId?: string; onDropComplete?: () => void; - // Translation callbacks for toast messages - onSuccess?: (count: number, mailboxName: string) => void; + // Translation callbacks for toast messages. `restore` says where the + // messages were, for the toast's Undo; null when the move went to another + // account, which Undo can't take back. + onSuccess?: (count: number, mailboxName: string, restore: EmailRestoreBatch[] | null) => void; onError?: (error: string) => void; } @@ -132,7 +134,12 @@ export function useMailboxDrop({ mailbox, accountId, onDropComplete, onSuccess, let isCrossAccount = false; let moved: boolean; + let restore: EmailRestoreBatch[] | null = null; if (isAggregateListView()) { + // Undo only when every row moved inside its own account. + const inPlace = draggedEmails.length > 0 && draggedEmails.every(em => + em.sourceAccountId === mailbox.accountId && (!em.sourceClientAccountId || em.sourceClientAccountId === destAccountId)); + if (inPlace) restore = restoreBatchesFor(draggedEmails, client); // A list that mixes accounts (unified, tag and "All folders" views) // stamps every row with its login and account. The store moves each // of those groups apart - in place only in the folder's own account, @@ -190,6 +197,7 @@ export function useMailboxDrop({ mailbox, accountId, onDropComplete, onSuccess, moved = await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride, sourceJmapOverride); } else { // Single-account or same-account-shared move: bulk JMAP request. + restore = restoreBatchesFor(draggedEmails.filter(em => emailIds.includes(em.id)), client); moved = await moveEmailsToMailbox(client, emailIds, mailbox.id); } } @@ -218,7 +226,7 @@ export function useMailboxDrop({ mailbox, accountId, onDropComplete, onSuccess, const mailboxPath = getMailboxPath(mailbox, mailboxes); if (onSuccess) { - onSuccess(emailIds.length, mailboxPath); + onSuccess(emailIds.length, mailboxPath, restore); } else { if (emailIds.length === 1) { toast.success("Email moved", `Moved to ${mailboxPath}`); diff --git a/hooks/use-remove-from-folder.ts b/hooks/use-remove-from-folder.ts index 3f7921e4a..9c96487b8 100644 --- a/hooks/use-remove-from-folder.ts +++ b/hooks/use-remove-from-folder.ts @@ -3,14 +3,14 @@ import { useCallback } from "react"; import { useTranslations } from "next-intl"; import { useAuthStore } from "@/stores/auth-store"; -import { useEmailStore, removalFolder } from "@/stores/email-store"; -import { runBatchEmailAction } from "@/lib/email-action-toast"; +import { useEmailStore, removalFolder, restoreBatchesFor } from "@/stores/email-store"; +import { runBatchEmailAction, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; import { localizeMailboxName } from "@/lib/mailbox-label"; import type { Email } from "@/lib/jmap/types"; /** * "Remove from this folder" (#615): takes the messages out of the open folder - * only, keeps them in their other folders, and toasts the outcome. + * only, keeps them in their other folders, and toasts the outcome with Undo. */ export function useRemoveFromFolder(): (emails: Email[]) => Promise { const t = useTranslations("notifications"); @@ -24,9 +24,11 @@ export function useRemoveFromFolder(): (emails: Email[]) => Promise { const mailbox = folder ? localizeMailboxName(folder.role, folder.name, (key) => tSidebar(`mailboxes.${key}`)) : ""; + const restore = restoreBatchesFor(emails, client); await runBatchEmailAction(() => removeFromMailbox(client, emails), { success: t("removed_from_folder", { mailbox }), error: t("remove_from_folder_failed"), + undo: undoMoveAction(client, restore, undoLabels(t)), }); }, [client, removeFromMailbox, t, tSidebar]); } diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 223dc4f19..e36d230ee 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -652,6 +652,19 @@ export class DemoJMAPClient implements IJMAPClient { this.recalcMailboxCounts(); } + async restoreEmails(emails: Array<{ id: string; mailboxIds: Record; seen?: boolean }>): Promise { + for (const { id, mailboxIds, seen } of emails) { + const email = this.data.emails.find(e => e.id === id); + if (!email) continue; + email.mailboxIds = { ...mailboxIds }; + if (seen !== undefined) { + if (seen) email.keywords.$seen = true; + else delete email.keywords.$seen; + } + } + this.recalcMailboxCounts(); + } + async emptyMailbox(mailboxId: string, _accountId?: string): Promise { const before = this.data.emails.length; this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]); diff --git a/lib/email-action-toast.ts b/lib/email-action-toast.ts index facba08c2..ed5c53f89 100644 --- a/lib/email-action-toast.ts +++ b/lib/email-action-toast.ts @@ -1,5 +1,58 @@ import { toast } from "@/stores/toast-store"; -import { useEmailStore } from "@/stores/email-store"; +import { useEmailStore, type EmailRestoreBatch } from "@/stores/email-store"; +import type { ToastAction } from "@/components/ui/toast"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; + +/** How long a toast with Undo stays, so there is time to reach the button. */ +export const UNDO_TOAST_DURATION = 8000; + +/** The words the Undo action needs, from the `notifications` namespace. */ +export interface UndoLabels { + undo: string; + done: string; + failed: string; +} + +/** `t` of the `notifications` namespace → UndoLabels. */ +export function undoLabels(t: (key: string) => string): UndoLabels { + return { undo: t("undo"), done: t("undo_done"), failed: t("undo_failed") }; +} + +/** + * The toast's Undo for a reversible move (delete to Trash, archive, move, + * report spam, remove from folder): puts the messages back where `batches` + * says they were. None when there is nothing to put back. + */ +export function undoMoveAction( + client: IJMAPClient | null | undefined, + batches: EmailRestoreBatch[] | null | undefined, + labels: UndoLabels, +): ToastAction | undefined { + if (!client || !batches || !batches.some(b => b.points.length > 0)) return undefined; + return { + label: labels.undo, + onClick: () => { + void (async () => { + try { + await useEmailStore.getState().restoreEmails(client, batches); + toast.success(labels.done); + } catch (error) { + console.error("Failed to undo:", error); + toast.error(labels.failed); + } + })(); + }, + }; +} + +/** A success toast, with Undo when the action can be undone. */ +export function toastWithUndo(title: string, undo: ToastAction | undefined, message?: string): void { + if (!undo) { + toast.success(title, message); + return; + } + toast.success(title, { message, action: undo, duration: UNDO_TOAST_DURATION }); +} /** * Runs a bulk email-store action and toasts the outcome: a failure always, a @@ -7,11 +60,13 @@ import { useEmailStore } from "@/stores/email-store"; * through `state.error` instead of throwing (batchArchive does both), so a * resolved promise alone does not mean the mail moved. An action that * resolves `false` did nothing: a plugin vetoed it (and told the user why) or - * there was nothing to act on, so there is no success to report. + * there was nothing to act on, so there is no success to report. `undo` goes + * on the success toast only: after a partial failure the list is reloaded and + * the error says what stayed. */ export async function runBatchEmailAction( run: () => Promise, - messages: { success?: string; error: string; describeError?: (error: unknown) => string | undefined }, + messages: { success?: string; error: string; describeError?: (error: unknown) => string | undefined; undo?: ToastAction }, ): Promise { // An error left over from an earlier action is not this action's outcome. useEmailStore.setState({ error: null }); @@ -28,6 +83,6 @@ export async function runBatchEmailAction( if (storeError) { toast.error(messages.error, storeError); } else if (ran !== false && messages.success) { - toast.success(messages.success); + toastWithUndo(messages.success, messages.undo); } } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 551f32010..55a13299e 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -360,6 +360,12 @@ export interface IJMAPClient { * least one mailbox, so the server refuses this for a message only in it. */ removeEmailsFromMailbox(emailIds: string[], mailboxId: string, accountId?: string): Promise; + /** + * Put messages back where they were before a move, delete to Trash, + * archive or "remove from folder": each one's whole `mailboxIds`, and its + * read state when `seen` is given. Backs the toasts' Undo. + */ + restoreEmails(emails: Array<{ id: string; mailboxIds: Record; seen?: boolean }>, accountId?: string): Promise; emptyMailbox(mailboxId: string, accountId?: string): Promise; moveMailboxContents(fromMailboxId: string, toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; markMailboxAsRead(mailboxId: string, accountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 3d76ace05..0ec6b2ad7 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -3600,6 +3600,20 @@ export class JMAPClient implements IJMAPClient { } } + async restoreEmails(emails: Array<{ id: string; mailboxIds: Record; seen?: boolean }>, accountId?: string): Promise { + if (emails.length === 0) return; + for (const batch of batched(emails, this.getMaxObjectsInSet())) { + const update = Object.fromEntries(batch.map(email => [email.id, { + mailboxIds: email.mailboxIds, + ...(email.seen === undefined ? {} : { 'keywords/$seen': email.seen ? true : null }), + }])); + const response = await this.request([ + ["Email/set", { accountId: accountId || this.accountId, update }, "0"], + ]); + assertSetSucceeded(response, 'Failed to restore emails'); + } + } + async emptyMailbox(mailboxId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; const batchSize = Math.min(500, this.getMaxObjectsInSet()); diff --git a/locales/ar/common.json b/locales/ar/common.json index f302c0a3a..844f37d94 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -940,6 +940,9 @@ "move_failed": "فشل النقل", "removed_from_folder": "تمت الإزالة من {mailbox}", "remove_from_folder_failed": "تعذرت الإزالة من هذا المجلد", + "undo": "تراجع", + "undo_done": "تمت الإعادة إلى مكانها", + "undo_failed": "تعذّر التراجع", "emails_copied": "تم نسخ {count} رسالة", "copy_failed": "فشل النسخ", "move_error": "تعذّر نقل الرسائل إلى المجلد المحدد", diff --git a/locales/ca/common.json b/locales/ca/common.json index c74f3a6e6..0464fde9c 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -940,6 +940,9 @@ "move_failed": "No s'ha pogut moure", "removed_from_folder": "S'ha tret de {mailbox}", "remove_from_folder_failed": "No s'ha pogut treure d'aquesta carpeta", + "undo": "Desfés", + "undo_done": "S'ha tornat al lloc", + "undo_failed": "No s'ha pogut desfer", "emails_copied": "{count} correus copiats", "copy_failed": "No s'ha pogut copiar", "move_error": "No s'han pogut moure els correus a la carpeta seleccionada", diff --git a/locales/cs/common.json b/locales/cs/common.json index 04311030b..0300a9854 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -940,6 +940,9 @@ "move_failed": "Přesun selhal", "removed_from_folder": "Odebráno ze složky {mailbox}", "remove_from_folder_failed": "Odebrání z této složky se nezdařilo", + "undo": "Zpět", + "undo_done": "Vráceno zpět", + "undo_failed": "Nepodařilo se vrátit zpět", "emails_copied": "{count} zpráv bylo zkopírováno", "copy_failed": "Kopírování selhalo", "move_error": "Zprávu nelze přesunout do vybrané složky", diff --git a/locales/da/common.json b/locales/da/common.json index 517caa46f..0040d10c4 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -940,6 +940,9 @@ "move_failed": "Flytning mislykkedes", "removed_from_folder": "Fjernet fra {mailbox}", "remove_from_folder_failed": "Kunne ikke fjerne fra denne mappe", + "undo": "Fortryd", + "undo_done": "Flyttet tilbage", + "undo_failed": "Kunne ikke fortrydes", "emails_copied": "{count} e-mails kopieret", "copy_failed": "Kopiering mislykkedes", "move_error": "Kunne ikke flytte e-mails til den valgte mappe", diff --git a/locales/de/common.json b/locales/de/common.json index 141274200..5e74c34d0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -940,6 +940,9 @@ "move_failed": "Verschieben fehlgeschlagen", "removed_from_folder": "Aus {mailbox} entfernt", "remove_from_folder_failed": "Konnte nicht aus diesem Ordner entfernt werden", + "undo": "Rückgängig", + "undo_done": "Zurückverschoben", + "undo_failed": "Konnte nicht rückgängig gemacht werden", "emails_copied": "{count} E-Mails kopiert", "copy_failed": "Kopieren fehlgeschlagen", "move_error": "E-Mails konnten nicht in den ausgewählten Ordner verschoben werden", diff --git a/locales/en/common.json b/locales/en/common.json index e25ebab8b..d07f5b142 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -940,6 +940,9 @@ "move_failed": "Move failed", "removed_from_folder": "Removed from {mailbox}", "remove_from_folder_failed": "Could not remove from this folder", + "undo": "Undo", + "undo_done": "Moved back", + "undo_failed": "Couldn't undo", "emails_copied": "{count} emails copied", "copy_failed": "Copy failed", "move_error": "Could not move emails to the selected folder", diff --git a/locales/es/common.json b/locales/es/common.json index 5dd4f01d5..93a77d110 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -940,6 +940,9 @@ "move_failed": "Error al mover", "removed_from_folder": "Quitado de {mailbox}", "remove_from_folder_failed": "No se pudo quitar de esta carpeta", + "undo": "Deshacer", + "undo_done": "Devuelto a su sitio", + "undo_failed": "No se pudo deshacer", "emails_copied": "{count} correos copiados", "copy_failed": "Error al copiar", "move_error": "No se pudieron mover los correos a la carpeta seleccionada", diff --git a/locales/et/common.json b/locales/et/common.json index 36cbb878a..a73af5a68 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -940,6 +940,9 @@ "move_failed": "Liigutamine ebaõnnestus", "removed_from_folder": "Eemaldatud kaustast {mailbox}", "remove_from_folder_failed": "Sellest kaustast ei õnnestunud eemaldada", + "undo": "Võta tagasi", + "undo_done": "Tõstetud tagasi", + "undo_failed": "Tagasivõtmine ei õnnestunud", "emails_copied": "{count} kirja kopeeritud", "copy_failed": "Kopeerimine ebaõnnestus", "move_error": "E-kirju ei õnnestunud valitud kausta liigutada", diff --git a/locales/fa/common.json b/locales/fa/common.json index 09ab7af4f..e2c3de0f4 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -940,6 +940,9 @@ "move_failed": "انتقال ناموفق بود", "removed_from_folder": "از {mailbox} حذف شد", "remove_from_folder_failed": "حذف از این پوشه انجام نشد", + "undo": "بازگردانی", + "undo_done": "به جای قبلی برگردانده شد", + "undo_failed": "بازگردانی ممکن نشد", "emails_copied": "{count} ایمیل کپی شد", "copy_failed": "کپی ناموفق بود", "move_error": "انتقال ایمیل‌ها به پوشه انتخاب شده ممکن نیست", diff --git a/locales/fr/common.json b/locales/fr/common.json index 8be4a0523..82f7ac4ef 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -940,6 +940,9 @@ "move_failed": "Échec du déplacement", "removed_from_folder": "Retiré de {mailbox}", "remove_from_folder_failed": "Impossible de retirer de ce dossier", + "undo": "Annuler", + "undo_done": "Remis à sa place", + "undo_failed": "Impossible d'annuler", "emails_copied": "{count} emails copiés", "copy_failed": "Échec de la copie", "move_error": "Impossible de déplacer les emails vers le dossier sélectionné", diff --git a/locales/he/common.json b/locales/he/common.json index 9ad7d19ef..9788be609 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -905,6 +905,9 @@ "move_failed": "המהלך נכשל", "removed_from_folder": "הוסר מ-{mailbox}", "remove_from_folder_failed": "לא ניתן להסיר מתיקייה זו", + "undo": "בטל", + "undo_done": "הוחזר למקומו", + "undo_failed": "לא ניתן היה לבטל", "emails_copied": "{count} הודעות הועתקו", "copy_failed": "ההעתקה נכשלה", "move_error": "לא ניתן היה להעביר הודעות דוא\"ל לתיקיה שנבחרה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 758e15995..f9207daf2 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -940,6 +940,9 @@ "move_failed": "Áthelyezés sikertelen", "removed_from_folder": "Eltávolítva innen: {mailbox}", "remove_from_folder_failed": "Nem sikerült eltávolítani ebből a mappából", + "undo": "Visszavonás", + "undo_done": "Visszahelyezve", + "undo_failed": "Nem sikerült visszavonni", "emails_copied": "{count} e-mail másolva", "copy_failed": "Másolás sikertelen", "move_error": "Nem sikerült áthelyezni az e-maileket a kiválasztott mappába", diff --git a/locales/it/common.json b/locales/it/common.json index cb2f68866..4fb024c49 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -940,6 +940,9 @@ "move_failed": "Spostamento non riuscito", "removed_from_folder": "Rimosso da {mailbox}", "remove_from_folder_failed": "Impossibile rimuovere da questa cartella", + "undo": "Annulla", + "undo_done": "Rimesso al suo posto", + "undo_failed": "Impossibile annullare", "emails_copied": "{count} messaggi copiati", "copy_failed": "Copia non riuscita", "move_error": "Impossibile spostare i messaggi nella cartella selezionata", diff --git a/locales/ja/common.json b/locales/ja/common.json index a7a76b7bb..bfd62bfb3 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -940,6 +940,9 @@ "move_failed": "移動に失敗しました", "removed_from_folder": "{mailbox} から削除しました", "remove_from_folder_failed": "このフォルダから削除できませんでした", + "undo": "元に戻す", + "undo_done": "元の場所に戻しました", + "undo_failed": "元に戻せませんでした", "emails_copied": "{count}件のメールをコピーしました", "copy_failed": "コピーに失敗しました", "move_error": "選択したフォルダにメールを移動できませんでした", diff --git a/locales/ko/common.json b/locales/ko/common.json index 432de0376..93de6fc6c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -940,6 +940,9 @@ "move_failed": "이동에 실패했어요", "removed_from_folder": "{mailbox}에서 제거됨", "remove_from_folder_failed": "이 폴더에서 제거하지 못했습니다", + "undo": "실행 취소", + "undo_done": "원래 위치로 되돌렸어요", + "undo_failed": "실행 취소하지 못했어요", "emails_copied": "{count}개의 메일이 복사되었어요", "copy_failed": "복사에 실패했어요", "move_error": "선택한 폴더로 메일을 이동할 수 없어요", diff --git a/locales/lv/common.json b/locales/lv/common.json index be3aa7cb9..ac97d7da6 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -940,6 +940,9 @@ "move_failed": "Pārvietošanas kļūda", "removed_from_folder": "Noņemts no {mailbox}", "remove_from_folder_failed": "Neizdevās noņemt no šīs mapes", + "undo": "Atsaukt", + "undo_done": "Pārvietots atpakaļ", + "undo_failed": "Neizdevās atsaukt", "emails_copied": "{count} vēstules nokopētas", "copy_failed": "Kopēšanas kļūda", "move_error": "Neizdevās pārvietot vēstules uz izvēlēto mapi", diff --git a/locales/mn/common.json b/locales/mn/common.json index 3ac66f6cb..dc0fa95a6 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -940,6 +940,9 @@ "move_failed": "Зөөж чадсангүй", "removed_from_folder": "{mailbox}-с хасагдлаа", "remove_from_folder_failed": "Энэ хавтаснаас хасаж чадсангүй", + "undo": "Буцаах", + "undo_done": "Буцааж шилжүүллээ", + "undo_failed": "Буцааж чадсангүй", "emails_copied": "{count} имэйлийг хуулсан", "copy_failed": "Хуулж чадсангүй", "move_error": "Имэйлүүдийг сонгосон хавтас руу зөөж чадсангүй", diff --git a/locales/nb/common.json b/locales/nb/common.json index bce385f8e..dfb5a0fde 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -940,6 +940,9 @@ "move_failed": "Kunne ikke flytte e-posten", "removed_from_folder": "Fjernet fra {mailbox}", "remove_from_folder_failed": "Kunne ikke fjerne fra denne mappen", + "undo": "Angre", + "undo_done": "Flyttet tilbake", + "undo_failed": "Kunne ikke angre", "emails_copied": "{count} e-poster er kopiert", "copy_failed": "Kunne ikke kopiere", "move_error": "Kunne ikke flytte e-poster til den valgte mappen", diff --git a/locales/nl/common.json b/locales/nl/common.json index 5b111f177..3bbade0da 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -940,6 +940,9 @@ "move_failed": "Verplaatsen mislukt", "removed_from_folder": "Verwijderd uit {mailbox}", "remove_from_folder_failed": "Kon niet uit deze map verwijderen", + "undo": "Ongedaan maken", + "undo_done": "Teruggezet", + "undo_failed": "Kon niet ongedaan worden gemaakt", "emails_copied": "{count} e-mails gekopieerd", "copy_failed": "Kopiëren mislukt", "move_error": "Kan e-mails niet verplaatsen naar de geselecteerde map", diff --git a/locales/pl/common.json b/locales/pl/common.json index 079d25f31..4ea5d2da6 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -940,6 +940,9 @@ "move_failed": "Przenoszenie nie powiodło się", "removed_from_folder": "Usunięto z folderu {mailbox}", "remove_from_folder_failed": "Nie udało się usunąć z tego folderu", + "undo": "Cofnij", + "undo_done": "Przeniesiono z powrotem", + "undo_failed": "Nie udało się cofnąć", "emails_copied": "{count} wiadomości skopiowanych", "copy_failed": "Kopiowanie nie powiodło się", "move_error": "Nie można przenieść wiadomości do wybranego folderu", diff --git a/locales/pt/common.json b/locales/pt/common.json index 38fcaf6d8..a9507f90b 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -940,6 +940,9 @@ "move_failed": "Falha ao mover", "removed_from_folder": "Removido de {mailbox}", "remove_from_folder_failed": "Não foi possível remover desta pasta", + "undo": "Desfazer", + "undo_done": "Devolvido ao lugar", + "undo_failed": "Não foi possível desfazer", "emails_copied": "{count} e-mails copiados", "copy_failed": "Falha ao copiar", "move_error": "Não foi possível mover os e-mails para a pasta selecionada", diff --git a/locales/ro/common.json b/locales/ro/common.json index 88868221f..f035ff47a 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -940,6 +940,9 @@ "move_failed": "Mutarea a eșuat", "removed_from_folder": "Eliminat din {mailbox}", "remove_from_folder_failed": "Nu s-a putut elimina din acest dosar", + "undo": "Anulează", + "undo_done": "Mutat înapoi", + "undo_failed": "Nu s-a putut anula", "emails_copied": "{count} e-mailuri copiate", "copy_failed": "Copierea a eșuat", "move_error": "Nu s-au putut muta e-mailurile în folderul selectat", diff --git a/locales/ru/common.json b/locales/ru/common.json index 63eafde10..8e954e3fc 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -940,6 +940,9 @@ "move_failed": "Ошибка перемещения", "removed_from_folder": "Убрано из папки {mailbox}", "remove_from_folder_failed": "Не удалось убрать из этой папки", + "undo": "Отменить", + "undo_done": "Возвращено на место", + "undo_failed": "Не удалось отменить", "emails_copied": "{count} писем скопировано", "copy_failed": "Ошибка копирования", "move_error": "Не удалось переместить письма в выбранную папку", diff --git a/locales/sk/common.json b/locales/sk/common.json index 29ba9a7e2..a89d7d2eb 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -940,6 +940,9 @@ "move_failed": "Presun zlyhal", "removed_from_folder": "Odstránené z priečinka {mailbox}", "remove_from_folder_failed": "Odstránenie z tohto priečinka zlyhalo", + "undo": "Späť", + "undo_done": "Presunuté späť", + "undo_failed": "Nepodarilo sa vrátiť späť", "emails_copied": "{count} e-mailov bolo skopírovaných", "copy_failed": "Kopírovanie zlyhalo", "move_error": "E-maily sa nedá presunúť do vybratej priečinky", diff --git a/locales/th/common.json b/locales/th/common.json index 8ed6ed3ef..e6323ee1d 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -382,6 +382,9 @@ "move_failed": "ย้ายไม่สำเร็จ", "removed_from_folder": "นำออกจาก {mailbox} แล้ว", "remove_from_folder_failed": "ไม่สามารถนำออกจากโฟลเดอร์นี้ได้", + "undo": "เลิกทำ", + "undo_done": "ย้ายกลับแล้ว", + "undo_failed": "ไม่สามารถเลิกทำได้", "emails_copied": "{count} อีเมลถูกคัดลอกแล้ว", "copy_failed": "คัดลอกไม่สำเร็จ", "move_error": "ไม่สามารถย้ายอีเมลไปยังโฟลเดอร์ที่เลือกได้", diff --git a/locales/tr/common.json b/locales/tr/common.json index 6175b06c0..5a5aa101c 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -940,6 +940,9 @@ "move_failed": "Taşıma başarısız", "removed_from_folder": "{mailbox} klasöründen kaldırıldı", "remove_from_folder_failed": "Bu klasörden kaldırılamadı", + "undo": "Geri al", + "undo_done": "Geri taşındı", + "undo_failed": "Geri alınamadı", "emails_copied": "{count} e-posta kopyalandı", "copy_failed": "Kopyalama başarısız", "move_error": "E-postalar seçilen klasöre taşınamadı", diff --git a/locales/uk/common.json b/locales/uk/common.json index b6c42bf7c..33a485b5f 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -940,6 +940,9 @@ "move_failed": "Не вдалося перемістити", "removed_from_folder": "Прибрано з папки {mailbox}", "remove_from_folder_failed": "Не вдалося прибрати з цієї папки", + "undo": "Скасувати", + "undo_done": "Повернуто на місце", + "undo_failed": "Не вдалося скасувати", "emails_copied": "{count} електронних листів скопійовано", "copy_failed": "Не вдалося скопіювати", "move_error": "Не вдалося перемістити електронні листи до вибраної папки", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 3c122fa20..b3131374f 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -940,6 +940,9 @@ "move_failed": "移動郵件失敗", "removed_from_folder": "已從 {mailbox} 移除", "remove_from_folder_failed": "無法從此資料夾移除", + "undo": "復原", + "undo_done": "已移回", + "undo_failed": "無法復原", "emails_copied": "{count} 封郵件已複製", "copy_failed": "複製失敗", "move_error": "無法將郵件移至選取的信件匣", diff --git a/locales/zh/common.json b/locales/zh/common.json index 890c771e7..36b741cdc 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -940,6 +940,9 @@ "move_failed": "移动失败", "removed_from_folder": "已从 {mailbox} 中移除", "remove_from_folder_failed": "无法从此文件夹中移除", + "undo": "撤销", + "undo_done": "已移回", + "undo_failed": "无法撤销", "emails_copied": "{count} 封邮件已复制", "copy_failed": "复制失败", "move_error": "无法将邮件移至所选文件夹", diff --git a/stores/__tests__/email-archive.test.ts b/stores/__tests__/email-archive.test.ts index 94c33d4db..3972386e7 100644 --- a/stores/__tests__/email-archive.test.ts +++ b/stores/__tests__/email-archive.test.ts @@ -108,4 +108,32 @@ describe('email-store archive thread behavior', () => { expect(client.batchMoveEmails).toHaveBeenCalledWith(['email-1', 'email-2'], 'server-archive', 'shared-account'); }); + + it('tells Undo where the archived messages were, and Undo puts them back', async () => { + const client = { + getThread: vi.fn().mockResolvedValue({ id: 'thread-1', emailIds: ['email-1', 'email-2', 'sent-1'] }), + getEmailFields: vi.fn().mockResolvedValue([ + { id: 'email-1', mailboxIds: { 'server-inbox': true }, keywords: { $seen: true } }, + { id: 'email-2', mailboxIds: { 'server-inbox': true, 'server-work': true }, keywords: {} }, + { id: 'sent-1', mailboxIds: { 'server-sent': true }, keywords: { $seen: true } }, + ]), + batchMoveEmails: vi.fn().mockResolvedValue(undefined), + restoreEmails: vi.fn().mockResolvedValue(undefined), + } as unknown as IJMAPClient; + const onMoved = vi.fn(); + useEmailStore.setState({ refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined), fetchMailboxes: vi.fn().mockResolvedValue(undefined) }); + + await useEmailStore.getState().moveThreadToMailbox(client, 'email-1', 'archive-local', undefined, onMoved); + + expect(onMoved).toHaveBeenCalledTimes(1); + const [batches] = onMoved.mock.calls[0]; + expect(batches).toEqual([{ client, accountId: 'shared-account', points: [ + { id: 'email-1', mailboxIds: { 'server-inbox': true }, seen: true }, + { id: 'email-2', mailboxIds: { 'server-inbox': true, 'server-work': true }, seen: false }, + ] }]); + + await useEmailStore.getState().restoreEmails(client, batches); + expect(client.restoreEmails).toHaveBeenCalledWith(batches[0].points, 'shared-account'); + expect(useEmailStore.getState().refreshCurrentMailbox).toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/stores/email-store.ts b/stores/email-store.ts index 2a451df0e..1c54ca3a8 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -96,6 +96,59 @@ export function resolveSelectedEmails(state: { return [...found.values()]; } +/** A message's folders and read state before an action moved it. */ +export interface EmailRestorePoint { + id: string; + /** The server's own folder ids, not the list's `account:id` names. */ + mailboxIds: Record; + seen: boolean; +} + +/** Restore points of one login and JMAP account, as the action reached them. */ +export interface EmailRestoreBatch { + client: IJMAPClient; + accountId?: string; + points: EmailRestorePoint[]; +} + +/** + * Where `emails` are now, so the toast's Undo can put them back after a + * delete to Trash, an archive, a move or "remove from folder". Taken before + * the action, routed the way the action is (resolveEmailActionContext). + */ +export function restoreBatchesFor(emails: Email[], client: IJMAPClient): EmailRestoreBatch[] { + const batches: EmailRestoreBatch[] = []; + for (const email of emails) { + if (!email.mailboxIds) continue; + let context: ReturnType; + try { + context = resolveEmailActionContext(email, client); + } catch { + continue; + } + const prefix = context.accountId ? `${context.accountId}:` : null; + const mailboxIds: Record = {}; + for (const [id, inIt] of Object.entries(email.mailboxIds)) { + if (inIt) mailboxIds[prefix && id.startsWith(prefix) ? id.slice(prefix.length) : id] = true; + } + if (Object.keys(mailboxIds).length === 0) continue; + let batch = batches.find(b => b.client === context.client && b.accountId === context.accountId); + if (!batch) { + batch = { client: context.client, accountId: context.accountId, points: [] }; + batches.push(batch); + } + if (!batch.points.some(p => p.id === email.id)) { + batch.points.push({ id: email.id, mailboxIds, seen: !!email.keywords?.$seen }); + } + } + return batches; +} + +/** restoreBatchesFor the selected messages, taken before a batch action. */ +export function restoreBatchesForSelection(client: IJMAPClient): EmailRestoreBatch[] { + return restoreBatchesFor(resolveSelectedEmails(useEmailStore.getState()), client); +} + /** Enough of an email to key a selection by its owning account. */ type SelectableEmail = Pick; @@ -367,9 +420,11 @@ interface EmailStore { * (the open folder, else the one folder the message is in); the others, * such as your own message in Sent, stay where they are. `emailKey` * (emailKeyFor of the row) picks the right row when the id repeats across - * accounts. + * accounts. `onMoved` gets where the moved messages were, for Undo. */ - moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string, emailKey?: string) => Promise; + moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string, emailKey?: string, onMoved?: (restore: EmailRestoreBatch[]) => void) => Promise; + /** Undo for a move: put the messages back (restoreBatchesFor) and reload the list and counts. */ + restoreEmails: (client: IJMAPClient, batches: EmailRestoreBatch[]) => Promise; /** * Take messages out of the open folder only, leaving them in their other * folders (#615). Acts on those of `emails` that removalFolder and @@ -4844,15 +4899,22 @@ export const useEmailStore = create((set, get) => ({ } }, - moveThreadToMailbox: async (client, emailId, destinationMailboxId, emailKey): Promise => { + moveThreadToMailbox: async (client, emailId, destinationMailboxId, emailKey, onMoved): Promise => { const isTarget = singleEmailMatcher(emailId, emailKey); try { const state = get(); const email = state.emails.find(isTarget) ?? ((!!state.selectedEmail && isTarget(state.selectedEmail)) ? state.selectedEmail : null); + // The one message moves on its own: Undo puts back what the row says. + const moveOne = async () => { + const restore = email ? restoreBatchesFor([email], client) : []; + const moved = await get().moveToMailbox(client, emailId, destinationMailboxId, emailKey); + if (moved) onMoved?.(restore); + return moved; + }; if (!email?.threadId) { - return await get().moveToMailbox(client, emailId, destinationMailboxId, emailKey); + return await moveOne(); } // In unified view route to the email's own account (client + that @@ -4870,16 +4932,20 @@ export const useEmailStore = create((set, get) => ({ const source = sourceId ? mailboxes.find(mb => mb.id === sourceId) : undefined; const sourceJmapId = source ? (source.originalId || source.id) : null; let threadEmailIds = [emailId]; + let restorePoints: EmailRestorePoint[] = []; if (sourceJmapId && memberIds.length > 1) { - const members = await effectiveClient.getEmailFields(memberIds, ['id', 'mailboxIds'], accountId); - const inSource = members - .filter(m => (m.mailboxIds as Record | undefined)?.[sourceJmapId]) - .map(m => m.id as string); - threadEmailIds = [...new Set([emailId, ...inSource])]; + const members = await effectiveClient.getEmailFields(memberIds, ['id', 'mailboxIds', 'keywords'], accountId); + const inSource = members.filter(m => (m.mailboxIds as Record | undefined)?.[sourceJmapId]); + threadEmailIds = [...new Set([emailId, ...inSource.map(m => m.id as string)])]; + restorePoints = inSource.map(m => ({ + id: m.id as string, + mailboxIds: { ...(m.mailboxIds as Record) }, + seen: !!(m.keywords as Record | undefined)?.$seen, + })); } if (threadEmailIds.length <= 1) { - return await get().moveToMailbox(client, emailId, destinationMailboxId, emailKey); + return await moveOne(); } const move = { emailIds: threadEmailIds, fromMailboxId: sourceId, toMailboxId: destinationMailboxId }; @@ -4910,6 +4976,11 @@ export const useEmailStore = create((set, get) => ({ }; }); emitEmailMove(move, mailboxes); + if (onMoved) { + const picked = restoreBatchesFor([email], client)[0]?.points ?? []; + const points = [...restorePoints, ...picked.filter(p => !restorePoints.some(r => r.id === p.id))]; + onMoved([{ client: effectiveClient, accountId, points }]); + } } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to move email thread" @@ -4919,6 +4990,15 @@ export const useEmailStore = create((set, get) => ({ return true; }, + restoreEmails: async (client, batches): Promise => { + for (const batch of batches) { + if (batch.points.length > 0) await batch.client.restoreEmails(batch.points, batch.accountId); + } + // Back in the list the user is looking at, and the folder counts with it. + await get().refreshCurrentMailbox(client); + void get().fetchMailboxes(client); + }, + searchEmails: async (client, typedQuery) => { const { searchAbortController } = get();