From ed9fa2de455c85429df8b006f6cef47bbe01b5e1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:06:45 +0100 Subject: [PATCH] feat: offer Undo after a move to another account and in lists that mix accounts, moving the messages back into their folders --- components/email/email-list.tsx | 17 +-- components/email/mv-list-toolbar.tsx | 8 +- components/layout/sidebar.tsx | 4 +- components/mail/mail-app.tsx | 16 +-- hooks/__tests__/use-mailbox-drop.test.tsx | 8 +- hooks/use-mailbox-drop.ts | 19 +-- lib/__tests__/email-action-toast-undo.test.ts | 30 ++++ lib/email-action-toast.ts | 9 +- .../email-store-cross-account-undo.test.ts | 114 +++++++++++++++ stores/email-store.ts | 133 ++++++++++++++++-- 10 files changed, 306 insertions(+), 52 deletions(-) create mode 100644 lib/__tests__/email-action-toast-undo.test.ts create mode 100644 stores/__tests__/email-store-cross-account-undo.test.ts diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index bbd9c2136..4d0ed2fc0 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -21,7 +21,7 @@ import { Spinner } from "@/components/ui/spinner"; import { EmptyState } from "@/components/ui/empty-state"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; -import { useEmailStore, resolveEmailBlobContext, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, restoreBatchesForSelection, isAggregateListView, type ListRow } from "@/stores/email-store"; +import { useEmailStore, resolveEmailBlobContext, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, restoreBatchesForSelection, type ListRow , type CrossAccountMoveReceipt } from "@/stores/email-store"; import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder"; import { useShallow } from "zustand/react/shallow"; import { useAuthStore } from "@/stores/auth-store"; @@ -653,18 +653,15 @@ export function EmailList({ 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); + // Undo puts the messages back; those a mixed-account list moved to + // another account move back the same way (the receipts). + const restore = restoreBatchesForSelection(client); + const receipts: CrossAccountMoveReceipt[] = []; try { - await runBatchEmailAction(() => batchMoveToMailbox(client, mailboxId), { + await runBatchEmailAction(() => batchMoveToMailbox(client, mailboxId, undefined, receipts), { success: tNotifications('emails_moved', { count }), error: tNotifications('move_failed'), - undo: undoMoveAction(client, restore, undo), + undo: undoMoveAction(client, restore, undo, receipts), }); } finally { setTimeout(() => setIsProcessing(false), 500); diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index dcdb3f36f..6c37270bb 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -22,7 +22,7 @@ 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, restoreBatchesForSelection } from "@/stores/email-store"; +import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, restoreBatchesForSelection , type CrossAccountMoveReceipt } from "@/stores/email-store"; import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder"; import { runBatchEmailAction, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; import { toast } from "@/stores/toast-store"; @@ -332,10 +332,12 @@ export function MvListToolbar({ closeMove(); const count = selectionCount; const restore = client ? restoreBatchesForSelection(client) : null; - void run(async () => (client ? batchMoveToMailbox(client, mailboxId) : false), { + // What a mixed-account list moves to another account moves back that way. + const receipts: CrossAccountMoveReceipt[] = []; + void run(async () => (client ? batchMoveToMailbox(client, mailboxId, undefined, receipts) : false), { success: tNotifications("emails_moved", { count }), error: tNotifications("move_failed"), - undo: undoMoveAction(client, restore, undo), + undo: undoMoveAction(client, restore, undo, receipts), }); }; diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 4b2041725..0e6bf3892 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -667,10 +667,10 @@ function MailboxTreeItem({ const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({ mailbox: node, accountId: dragAccountId ?? undefined, - onSuccess: (count, mailboxName, restore) => { + onSuccess: (count, mailboxName, restore, receipts) => { toastWithUndo( count === 1 ? tNotifications('email_moved') : tNotifications('emails_moved', { count }), - undoMoveAction(useAuthStore.getState().client, restore, undoLabels(tNotifications)), + undoMoveAction(useAuthStore.getState().client, restore, undoLabels(tNotifications), receipts), tNotifications('moved_to_mailbox', { mailbox: mailboxName }), ); }, diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 8c76210da..d3d8f932d 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, restoreBatchesFor, restoreBatchesForSelection, TrashMailboxNotFoundError, type EmailRestoreBatch } 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, TrashMailboxNotFoundError, type EmailRestoreBatch , type CrossAccountMoveReceipt } 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"; @@ -3007,18 +3007,16 @@ 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. + // Undo: back to its folders; a move to another account moves back the + // same way (the receipts). 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; + const restore = email ? restoreBatchesFor([email], client) : null; + const receipts: CrossAccountMoveReceipt[] = []; try { - if (!(await moveToMailboxCrossAware(client, emailId, mailboxId, emailKey))) return; + if (!(await moveToMailboxCrossAware(client, emailId, mailboxId, emailKey, receipts))) return; toastWithUndo( t('notifications.email_moved'), - undoMoveAction(client, restore, undo), + undoMoveAction(client, restore, undo, receipts), destination && destinationList ? t('notifications.moved_to_mailbox', { mailbox: getMailboxPath(destination, destinationList) }) : undefined, diff --git a/hooks/__tests__/use-mailbox-drop.test.tsx b/hooks/__tests__/use-mailbox-drop.test.tsx index 8df4b81cb..222e579c5 100644 --- a/hooks/__tests__/use-mailbox-drop.test.tsx +++ b/hooks/__tests__/use-mailbox-drop.test.tsx @@ -92,8 +92,8 @@ describe('useMailboxDrop', () => { await drop(mailbox({}), ['m016', 't016']); expect(moveEmailsToMailbox.mock.calls).toEqual([ - [client016, ['m016', 't016'], 'f', 'userb@v10', drag.draggedEmails], - [client016, ['m016', 't016'], 'f', 'userb@v016', drag.draggedEmails], + [client016, ['m016', 't016'], 'f', 'userb@v10', drag.draggedEmails, []], + [client016, ['m016', 't016'], 'f', 'userb@v016', drag.draggedEmails, []], ]); expect(crossAccountMoveEmails).not.toHaveBeenCalled(); }); @@ -108,7 +108,7 @@ describe('useMailboxDrop', () => { await drop(mailbox({}), ['x']); - expect(moveEmailsToMailbox).toHaveBeenCalledWith(client016, ['x'], 'f', 'userb@v016', [dragged]); + expect(moveEmailsToMailbox).toHaveBeenCalledWith(client016, ['x'], 'f', 'userb@v016', [dragged], []); }); it('moves a drop onto another login\'s folder across accounts, although both logins are account `e`', async () => { @@ -118,7 +118,7 @@ describe('useMailboxDrop', () => { await drop(mailbox({}), ['m1'], 'userb@v10'); - expect(crossAccountMoveEmails).toHaveBeenCalledWith(new Map([['userb@v016', ['m1']]]), 'userb@v10', 'f', undefined, undefined); + expect(crossAccountMoveEmails).toHaveBeenCalledWith(new Map([['userb@v016', ['m1']]]), 'userb@v10', 'f', undefined, undefined, { receipts: [] }); expect(moveEmailsToMailbox).not.toHaveBeenCalled(); }); diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 89f1a6d78..c073550c0 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -3,7 +3,7 @@ import { useCallback, useState, DragEvent } from "react"; import { useTranslations } from "next-intl"; import { Mailbox, Email } from "@/lib/jmap/types"; -import { useEmailStore, isAggregateListView, restoreBatchesFor, type EmailRestoreBatch } from "@/stores/email-store"; +import { useEmailStore, isAggregateListView, restoreBatchesFor, type EmailRestoreBatch, type CrossAccountMoveReceipt } from "@/stores/email-store"; import { useShallow } from "zustand/react/shallow"; import { emailKeyFor } from "@/lib/thread-utils"; import { useAuthStore } from "@/stores/auth-store"; @@ -39,7 +39,8 @@ interface UseMailboxDropOptions { // 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; + /** `restore` and `receipts` are what Undo needs (undoMoveAction). */ + onSuccess?: (count: number, mailboxName: string, restore: EmailRestoreBatch[] | null, receipts: CrossAccountMoveReceipt[]) => void; onError?: (error: string) => void; } @@ -137,17 +138,16 @@ export function useMailboxDrop({ mailbox, accountId, onDropComplete, onSuccess, let isCrossAccount = false; let moved: boolean; let restore: EmailRestoreBatch[] | null = null; + // The messages taken to another account, so Undo can move them back. + const receipts: CrossAccountMoveReceipt[] = []; 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); + 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, // across accounts for the others. It gets the dragged rows: their // ids alone can name other accounts' rows too. - moved = await moveEmailsToMailbox(client, emailIds, mailbox.id, destAccountId, draggedEmails); + moved = await moveEmailsToMailbox(client, emailIds, mailbox.id, destAccountId, draggedEmails, receipts); } else { // Group dragged emails by source account. In single-account flows this // collapses to one bucket; in the Pro multi-account sidebar a single @@ -196,7 +196,8 @@ export function useMailboxDrop({ mailbox, accountId, onDropComplete, onSuccess, // through the viewing user's client, but the email/blob live in the // owner's JMAP account, so the copy/delete must target it. const sourceJmapOverride = sourceMb?.isShared ? sourceMb.accountId : undefined; - moved = await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride, sourceJmapOverride); + restore = restoreBatchesFor(draggedEmails.filter(em => emailIds.includes(em.id)), client); + moved = await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride, sourceJmapOverride, { receipts }); } else { // Single-account or same-account-shared move: bulk JMAP request. restore = restoreBatchesFor(draggedEmails.filter(em => emailIds.includes(em.id)), client); @@ -228,7 +229,7 @@ export function useMailboxDrop({ mailbox, accountId, onDropComplete, onSuccess, const mailboxPath = getMailboxPath(mailbox, mailboxes); if (onSuccess) { - onSuccess(emailIds.length, mailboxPath, restore); + onSuccess(emailIds.length, mailboxPath, restore, receipts); } else { if (emailIds.length === 1) { toast.success(t("email_moved"), t("moved_to_mailbox", { mailbox: mailboxPath })); diff --git a/lib/__tests__/email-action-toast-undo.test.ts b/lib/__tests__/email-action-toast-undo.test.ts new file mode 100644 index 000000000..4bcb8feed --- /dev/null +++ b/lib/__tests__/email-action-toast-undo.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); + +import { undoMoveAction } from '../email-action-toast'; +import { useEmailStore, type CrossAccountMoveReceipt, type EmailRestoreBatch } from '@/stores/email-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +const labels = { undo: 'Undo', done: 'Undone', failed: "Couldn't undo" }; + +describe('undoMoveAction', () => { + it('hands Undo the messages that went to another account, filled in after the move ran', async () => { + const undoMove = vi.fn().mockResolvedValue(undefined); + useEmailStore.setState({ undoMove: undoMove as never }); + const client = {} as IJMAPClient; + const batches: EmailRestoreBatch[] = [{ client, points: [{ id: 'e1', mailboxIds: { inbox: true }, seen: true }] }]; + const receipts: CrossAccountMoveReceipt[] = []; + + const action = undoMoveAction(client, batches, labels, receipts); + expect(action?.label).toBe('Undo'); + // The move runs after the toast's action was made. + receipts.push({ sourceLogin: 'a', sourceJmapAccountId: 'A', sourceEmailId: 'e1', destLogin: 'b', destJmapAccountId: 'B', destEmailId: 'c1' }); + + action!.onClick(); + await vi.waitFor(() => expect(toast.success).toHaveBeenCalledWith('Undone')); + expect(undoMove).toHaveBeenCalledWith(client, batches, receipts); + expect(undoMove.mock.calls[0][2]).toHaveLength(1); + }); +}); diff --git a/lib/email-action-toast.ts b/lib/email-action-toast.ts index ed5c53f89..d35c04e23 100644 --- a/lib/email-action-toast.ts +++ b/lib/email-action-toast.ts @@ -1,5 +1,5 @@ import { toast } from "@/stores/toast-store"; -import { useEmailStore, type EmailRestoreBatch } from "@/stores/email-store"; +import { useEmailStore, type EmailRestoreBatch, type CrossAccountMoveReceipt } from "@/stores/email-store"; import type { ToastAction } from "@/components/ui/toast"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; @@ -22,11 +22,16 @@ export function undoLabels(t: (key: string) => string): UndoLabels { * 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. + * + * `receipts` (filled while the move runs) name the messages it took to + * another account: Undo moves those back, the same way, into the folders + * their restore points name (undoMove). */ export function undoMoveAction( client: IJMAPClient | null | undefined, batches: EmailRestoreBatch[] | null | undefined, labels: UndoLabels, + receipts?: CrossAccountMoveReceipt[], ): ToastAction | undefined { if (!client || !batches || !batches.some(b => b.points.length > 0)) return undefined; return { @@ -34,7 +39,7 @@ export function undoMoveAction( onClick: () => { void (async () => { try { - await useEmailStore.getState().restoreEmails(client, batches); + await useEmailStore.getState().undoMove(client, batches, receipts ?? []); toast.success(labels.done); } catch (error) { console.error("Failed to undo:", error); diff --git a/stores/__tests__/email-store-cross-account-undo.test.ts b/stores/__tests__/email-store-cross-account-undo.test.ts new file mode 100644 index 000000000..5ad2e0cb2 --- /dev/null +++ b/stores/__tests__/email-store-cross-account-undo.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore, type CrossAccountMoveReceipt, type EmailRestoreBatch } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import type { Email } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +type Store = ReturnType; + +// Undo after a move to another account: the message moves back the way it +// went (copy back, delete the copy) and gets its folders and read state back. + +function makeEmail(id: string, mailboxServerId: string): Email { + return { + id, threadId: `t-${id}`, mailboxIds: { [mailboxServerId]: true }, keywords: {}, + size: 100, receivedAt: '2026-10-01T08:00:00Z', + from: [{ name: 'X', email: 'x@example.com' }], to: [{ name: 'Y', email: 'y@example.com' }], + subject: id, preview: '', hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {}, + }; +} + +describe('Undo of a move to another account (two servers)', () => { + const blob = new Blob(['From: x@example.com\r\n\r\nhi']); + let source: Record>; + let dest: Record>; + + beforeEach(() => { + source = { + getAccountId: vi.fn(() => 'acct-A'), + getEmail: vi.fn().mockResolvedValue({ id: 'e1', blobId: 'blob-1', keywords: { $seen: true }, receivedAt: '2026-10-01T08:00:00Z' }), + fetchBlob: vi.fn().mockResolvedValue(blob), + deleteEmail: vi.fn().mockResolvedValue(undefined), + importRawEmail: vi.fn().mockResolvedValue('back-1'), + restoreEmails: vi.fn().mockResolvedValue(undefined), + }; + dest = { + getAccountId: vi.fn(() => 'acct-B'), + importRawEmail: vi.fn().mockResolvedValue('copy-1'), + getEmail: vi.fn().mockResolvedValue({ id: 'copy-1', blobId: 'blob-2', keywords: { $seen: true }, receivedAt: '2026-10-01T08:00:00Z' }), + fetchBlob: vi.fn().mockResolvedValue(blob), + deleteEmail: vi.fn().mockResolvedValue(undefined), + }; + useAuthStore.setState({ + activeAccountId: 'login-A', + getClientForAccount: ((id: string) => (id === 'login-A' ? source : id === 'login-B' ? dest : undefined)) as never, + } as never); + useEmailStore.setState({ + emails: [makeEmail('e1', 'srv-inbox-A')], + fetchMailboxes: vi.fn().mockResolvedValue(undefined) as unknown as Store['fetchMailboxes'], + fetchAccountMailboxes: vi.fn().mockResolvedValue(undefined) as unknown as Store['fetchAccountMailboxes'], + refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined) as unknown as Store['refreshCurrentMailbox'], + }); + }); + + it('notes where each moved message went', async () => { + const receipts: CrossAccountMoveReceipt[] = []; + await useEmailStore.getState().crossAccountMoveEmails(new Map([['login-A', ['e1']]]), 'login-B', 'inbox-B', undefined, undefined, { receipts }); + expect(receipts).toEqual([{ + sourceLogin: 'login-A', sourceJmapAccountId: 'acct-A', sourceEmailId: 'e1', + destLogin: 'login-B', destJmapAccountId: 'acct-B', destEmailId: 'copy-1', + }]); + }); + + it('notes nothing for a copy, which keeps the original', async () => { + const receipts: CrossAccountMoveReceipt[] = []; + await useEmailStore.getState().crossAccountMoveEmails(new Map([['login-A', ['e1']]]), 'login-B', 'inbox-B', undefined, undefined, { keepOriginal: true, receipts }); + expect(receipts).toEqual([]); + }); + + it('moves the message back into its folders, read state included, and deletes the copy', async () => { + const receipts: CrossAccountMoveReceipt[] = [{ + sourceLogin: 'login-A', sourceJmapAccountId: 'acct-A', sourceEmailId: 'e1', + destLogin: 'login-B', destJmapAccountId: 'acct-B', destEmailId: 'copy-1', + }]; + // Taken before the move: in two folders, unread. Plus a message that + // stayed in its account. + const batches: EmailRestoreBatch[] = [{ + client: source as unknown as IJMAPClient, + points: [ + { id: 'e1', mailboxIds: { 'srv-inbox-A': true, 'srv-work-A': true }, seen: false }, + { id: 'e2', mailboxIds: { 'srv-inbox-A': true }, seen: true }, + ], + }]; + + await useEmailStore.getState().undoMove({} as IJMAPClient, batches, receipts); + + // Back the way it came: out of B, into A's first folder, the copy gone. + expect(dest.fetchBlob).toHaveBeenCalledWith('blob-2', undefined, undefined, undefined); + expect(source.importRawEmail).toHaveBeenCalledWith(blob, { 'srv-inbox-A': true }, { $seen: true }, undefined, '2026-10-01T08:00:00Z'); + expect(dest.deleteEmail).toHaveBeenCalledWith('copy-1', undefined); + // Its folders and read state as they were, under its new id; the other + // message is restored in place, under its own. + expect(source.restoreEmails).toHaveBeenCalledWith([{ id: 'e2', mailboxIds: { 'srv-inbox-A': true }, seen: true }], undefined); + expect(source.restoreEmails).toHaveBeenCalledWith([{ id: 'back-1', mailboxIds: { 'srv-inbox-A': true, 'srv-work-A': true }, seen: false }], undefined); + expect(source.restoreEmails).not.toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ id: 'e1' })]), undefined); + }); + + it('does not take an id of another account for the moved message', async () => { + // Account B's own `e1` stayed put; only account A's `e1` moved. + const receipts: CrossAccountMoveReceipt[] = [{ + sourceLogin: 'login-A', sourceJmapAccountId: 'acct-A', sourceEmailId: 'e1', + destLogin: 'login-B', destJmapAccountId: 'acct-B', destEmailId: 'copy-1', + }]; + const batches: EmailRestoreBatch[] = [ + { client: source as unknown as IJMAPClient, points: [{ id: 'e1', mailboxIds: { 'srv-inbox-A': true }, seen: true }] }, + { client: dest as unknown as IJMAPClient, points: [{ id: 'e1', mailboxIds: { 'srv-inbox-B': true }, seen: true }] }, + ]; + (dest as Record).restoreEmails = vi.fn().mockResolvedValue(undefined); + + await useEmailStore.getState().undoMove({} as IJMAPClient, batches, receipts); + + expect((dest as Record>).restoreEmails).toHaveBeenCalledWith([{ id: 'e1', mailboxIds: { 'srv-inbox-B': true }, seen: true }], undefined); + expect(source.importRawEmail).toHaveBeenCalledWith(blob, { 'srv-inbox-A': true }, { $seen: true }, undefined, '2026-10-01T08:00:00Z'); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index b65f1fa54..d84bc07bf 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -111,6 +111,32 @@ export interface EmailRestoreBatch { points: EmailRestorePoint[]; } +/** + * A message a move took to another account (crossAccountMoveEmails): its old + * id and account, and the copy's. Undo moves the copy back the same way and + * puts it in the folders the restore point names. + */ +export interface CrossAccountMoveReceipt { + /** The login (AccountEntry.id) and JMAP account the message left. */ + sourceLogin: string; + sourceJmapAccountId: string; + sourceEmailId: string; + /** The login and JMAP account it is in now, and its id there. */ + destLogin: string; + destJmapAccountId: string; + destEmailId: string; +} + +/** The restore point a receipt's message had before it left its account. */ +function receiptPoint(receipt: CrossAccountMoveReceipt, batches: EmailRestoreBatch[]): EmailRestorePoint | undefined { + for (const batch of batches) { + if ((batch.accountId ?? batch.client.getAccountId()) !== receipt.sourceJmapAccountId) continue; + const point = batch.points.find((candidate) => candidate.id === receipt.sourceEmailId); + if (point) return point; + } + return undefined; +} + /** * 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 @@ -415,14 +441,15 @@ interface EmailStore { * equivalent of what drag-and-drop already does. Falls back to the plain * single-account `moveToMailbox` when source and destination share an account. */ - moveToMailboxCrossAware: (client: IJMAPClient, emailId: string, mailboxId: string, emailKey?: string) => Promise; + /** `receipts` collects what went to another account, for Undo. */ + moveToMailboxCrossAware: (client: IJMAPClient, emailId: string, mailboxId: string, emailKey?: string, receipts?: CrossAccountMoveReceipt[]) => Promise; /** * `destLocalAccountId`: the login whose folder list `mailboxId` is from (a * drop target in the multi-account sidebar); by default the active one's. * `rows`: the rows that were dragged. Only a list that mixes accounts needs * them: an id alone can name rows of several accounts there. */ - moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string, destLocalAccountId?: string | null, rows?: Email[]) => Promise; + moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string, destLocalAccountId?: string | null, rows?: Email[], receipts?: CrossAccountMoveReceipt[]) => Promise; /** * Move the conversation's messages that are in the picked message's folder * (the open folder, else the one folder the message is in); the others, @@ -433,6 +460,12 @@ interface EmailStore { 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; + /** + * Undo for a move that took some messages to another account: those move + * back (the receipts) into the folders their restore points name, the + * others are restored in place. Then the list and counts reload. + */ + undoMove: (client: IJMAPClient, batches: EmailRestoreBatch[], receipts: CrossAccountMoveReceipt[]) => Promise; /** * Take messages out of the open folder only, leaving them in their other * folders (#615). Acts on those of `emails` that removalFolder and @@ -465,7 +498,7 @@ interface EmailStore { destMailboxId: string, destJmapAccountId?: string, sourceJmapAccountId?: string, - options?: { keepOriginal?: boolean }, + options?: { keepOriginal?: boolean; receipts?: CrossAccountMoveReceipt[] }, ) => Promise; /** * Copy emails into a folder of another connected account, keeping the @@ -520,7 +553,8 @@ interface EmailStore { * Acts on the selection, or on `target`'s messages when given; with a * target, `mailboxId` is the destination's JMAP id in that account. */ - batchMoveToMailbox: (client: IJMAPClient, mailboxId: string, target?: BatchActionTarget) => Promise; + /** `receipts` collects what went to another account, for Undo. */ + batchMoveToMailbox: (client: IJMAPClient, mailboxId: string, target?: BatchActionTarget, receipts?: CrossAccountMoveReceipt[]) => Promise; batchArchive: (client: IJMAPClient) => Promise; // Spam operations @@ -2280,6 +2314,7 @@ async function moveSourceBatches( batches: SourceBatch[], destination: MoveDestination, passedClient: IJMAPClient, + receipts?: CrossAccountMoveReceipt[], ): Promise<{ moved: T[]; failures: BatchFailure[] }> { const callerLogin = useEmailStore.getState().viewingAccountId ?? useAuthStore.getState().activeAccountId; const moved: T[] = []; @@ -2323,6 +2358,8 @@ async function moveSourceBatches( destination.mailboxId, destination.isShared ? destination.accountId : undefined, batch.accountId, + // Only when asked: a move nobody undoes passes no options. + ...(receipts ? [{ receipts }] : []), ); } catch (error) { const listed = new Set(useEmailStore.getState().emails.map((email) => email.id)); @@ -2337,8 +2374,8 @@ async function moveSourceBatches( * rows that moved leave the list and the selection, with their folders' * counters; the others stay. Returns what failed. */ -async function moveRowsToMailbox(rows: Email[], destination: MoveDestination, passedClient: IJMAPClient): Promise { - const { moved, failures } = await moveSourceBatches(groupBySource(rows), destination, passedClient); +async function moveRowsToMailbox(rows: Email[], destination: MoveDestination, passedClient: IJMAPClient, receipts?: CrossAccountMoveReceipt[]): Promise { + const { moved, failures } = await moveSourceBatches(groupBySource(rows), destination, passedClient, receipts); if (moved.length === 0) return failures; const movedKeys = new Set(moved.map(sourceRowKey)); const unread = moved.filter((email) => !email.keywords?.$seen).length; @@ -4439,7 +4476,7 @@ export const useEmailStore = create((set, get) => ({ return true; }, - moveToMailboxCrossAware: async (client, emailId, destinationMailboxId, emailKey): Promise => { + moveToMailboxCrossAware: async (client, emailId, destinationMailboxId, emailKey, receipts): Promise => { const isTarget = singleEmailMatcher(emailId, emailKey); const state = get(); const email = state.emails.find(isTarget); @@ -4452,7 +4489,7 @@ export const useEmailStore = create((set, get) => ({ const hookMailboxes = resolveActionMailboxes(); const move = { emailIds: [email.id], fromMailboxId: moveSourceMailboxId([email], hookMailboxes), toMailboxId: destinationMailboxId }; if (!(await allowEmailMove(move, hookMailboxes))) return false; - const failures = await moveRowsToMailbox([email], resolveMoveDestination(client, destinationMailboxId), client); + const failures = await moveRowsToMailbox([email], resolveMoveDestination(client, destinationMailboxId), client, receipts); if (failures.length > 0) throw failures[0].error; emitEmailMove(move, hookMailboxes); return true; @@ -4496,6 +4533,7 @@ export const useEmailStore = create((set, get) => ({ destMailbox!.originalId ?? destMailbox!.id, destMailbox!.isShared ? destMailbox!.accountId : undefined, sourceMailbox?.isShared ? sourceMailbox.accountId : undefined, + ...(receipts ? [{ receipts }] : []), ); }, @@ -4561,7 +4599,7 @@ export const useEmailStore = create((set, get) => ({ return true; }, - moveEmailsToMailbox: async (client, emailIds, destinationMailboxId, destLocalAccountId, rows): Promise => { + moveEmailsToMailbox: async (client, emailIds, destinationMailboxId, destLocalAccountId, rows, receipts): Promise => { if (emailIds.length === 0) return true; const idSet = new Set(emailIds); @@ -4578,6 +4616,7 @@ export const useEmailStore = create((set, get) => ({ moving, resolveMoveDestination(client, destinationMailboxId, destLocalAccountId), client, + receipts, ); const failed = new Set(failures.flatMap(f => f.ids)); const movedIds = move.emailIds.filter(id => !failed.has(id)); @@ -4722,6 +4761,8 @@ export const useEmailStore = create((set, get) => ({ crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId, sourceJmapAccountId, options): Promise => { if (emailIdsBySource.size === 0) return true; const keepOriginal = options?.keepOriginal === true; + // A copy keeps the original, so there is nothing to take back. + const receipts = keepOriginal ? undefined : options?.receipts; // A copy leaves the messages where they are, so only a move asks plugins. const hookMailboxes = [...resolveActionMailboxes(), ...(get().accountMailboxes[destAccountId] ?? [])]; const requestedIds = [...new Set([...emailIdsBySource.values()].flat())]; @@ -4761,14 +4802,23 @@ export const useEmailStore = create((set, get) => ({ // blob in a *delegated* account (blobNotFound), so the blob // copy+import path below is only valid across separate login // clients/servers. + const receipt = (destEmailId: string) => receipts?.push({ + sourceLogin: sourceAccountId, + sourceJmapAccountId: sourceJmapAccountId ?? sourceClient.getAccountId(), + sourceEmailId: emailId, + destLogin: destAccountId, + destJmapAccountId: destJmapAccountId ?? destClient.getAccountId(), + destEmailId, + }); if (sourceClient === destClient) { - await sourceClient.copyEmailAcrossAccounts( + const copyId = await sourceClient.copyEmailAcrossAccounts( emailId, sourceJmapAccountId ?? sourceClient.getAccountId(), destJmapAccountId ?? destClient.getAccountId(), destMailboxId, keepOriginal ? { keepOriginal } : undefined, ); + receipt(copyId); return emailId; } // Separate clients (cross-server multi-account): the email, its @@ -4783,8 +4833,9 @@ export const useEmailStore = create((set, get) => ({ // lib/jmap/server-keywords.ts). const keywords: Record = { ...(full.keywords ?? {}) }; // The original date, or the copy sorts as today's mail. - await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId, full.receivedAt); + const copyId = await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId, full.receivedAt); if (!keepOriginal) await sourceClient.deleteEmail(emailId, sourceJmapAccountId); + receipt(copyId); return emailId; }), ); @@ -5007,6 +5058,62 @@ export const useEmailStore = create((set, get) => ({ void get().fetchMailboxes(client); }, + undoMove: async (client, batches, receipts): Promise => { + if (receipts.length === 0) { + await get().restoreEmails(client, batches); + return; + } + // The messages that stayed in their account change folders back. + const across = (batch: EmailRestoreBatch, id: string) => receipts.some((r) => + r.sourceEmailId === id && r.sourceJmapAccountId === (batch.accountId ?? batch.client.getAccountId())); + const inPlace = batches + .map((batch) => ({ ...batch, points: batch.points.filter((point) => !across(batch, point.id)) })) + .filter((batch) => batch.points.length > 0); + for (const batch of inPlace) await batch.client.restoreEmails(batch.points, batch.accountId); + + // The others move back the way they came, into their first folder, then + // get all their folders and read state back. A message whose folders + // are unknown can't be put back where it was. + const groups = new Map }>(); + let unknown = 0; + for (const receipt of receipts) { + const point = receiptPoint(receipt, batches); + const folder = point && Object.keys(point.mailboxIds)[0]; + if (!point || !folder) { + unknown += 1; + continue; + } + const key = [receipt.destLogin, receipt.destJmapAccountId, receipt.sourceLogin, receipt.sourceJmapAccountId, folder].join('\u0000'); + if (!groups.has(key)) groups.set(key, { receipt, folder, items: [] }); + groups.get(key)!.items.push({ receipt, point }); + } + const auth = useAuthStore.getState(); + for (const { receipt, folder, items } of groups.values()) { + const back: CrossAccountMoveReceipt[] = []; + const sourceClient = auth.getClientForAccount(receipt.sourceLogin); + const destClient = auth.getClientForAccount(receipt.destLogin); + if (!sourceClient || !destClient) throw new Error('Account is not connected'); + await get().crossAccountMoveEmails( + new Map([[receipt.destLogin, items.map((item) => item.receipt.destEmailId)]]), + receipt.sourceLogin, + folder, + receipt.sourceJmapAccountId === sourceClient.getAccountId() ? undefined : receipt.sourceJmapAccountId, + receipt.destJmapAccountId === destClient.getAccountId() ? undefined : receipt.destJmapAccountId, + { receipts: back }, + ); + const points = back.flatMap((moved) => { + const item = items.find((candidate) => candidate.receipt.destEmailId === moved.sourceEmailId); + return item ? [{ ...item.point, id: moved.destEmailId }] : []; + }); + if (points.length > 0) { + await sourceClient.restoreEmails(points, receipt.sourceJmapAccountId === sourceClient.getAccountId() ? undefined : receipt.sourceJmapAccountId); + } + } + await get().refreshCurrentMailbox(client); + void get().fetchMailboxes(client); + if (unknown > 0) throw new Error(`${unknown} message(s) could not be put back`); + }, + searchEmails: async (client, typedQuery) => { const { searchAbortController } = get(); @@ -5760,7 +5867,7 @@ export const useEmailStore = create((set, get) => ({ return true; }, - batchMoveToMailbox: async (client, toMailboxId, target): Promise => { + batchMoveToMailbox: async (client, toMailboxId, target, receipts): Promise => { const { selectedEmailKeys, emails } = get(); // Keyed selection as in batchMarkAsRead; a targeted call names its own. const selection = target ? null : resolveSelectionRows(); @@ -5787,7 +5894,7 @@ export const useEmailStore = create((set, get) => ({ // What could not be moved stays listed and selected, and so does a // selected row found neither listed nor in an expanded thread // (resolveSelectionRows). - const failures = await moveRowsToMailbox(selectedEmails, resolveMoveDestination(client, toMailboxId), client); + const failures = await moveRowsToMailbox(selectedEmails, resolveMoveDestination(client, toMailboxId), client, receipts); for (const failure of failures) failure.ids.forEach(id => notMoved.add(id)); failures.push(...notFoundFailures(selection?.unknown ?? [])); set({ isLoading: false, error: failures.length > 0 ? batchFailureMessage(failures, 'move') : null });