mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: offer Undo after a move to another account and in lists that mix accounts, moving the messages back into their folders
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof useEmailStore.getState>;
|
||||
|
||||
// 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<string, ReturnType<typeof vi.fn>>;
|
||||
let dest: Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
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<string, unknown>).restoreEmails = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await useEmailStore.getState().undoMove({} as IJMAPClient, batches, receipts);
|
||||
|
||||
expect((dest as Record<string, ReturnType<typeof vi.fn>>).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');
|
||||
});
|
||||
});
|
||||
+120
-13
@@ -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<boolean>;
|
||||
/** `receipts` collects what went to another account, for Undo. */
|
||||
moveToMailboxCrossAware: (client: IJMAPClient, emailId: string, mailboxId: string, emailKey?: string, receipts?: CrossAccountMoveReceipt[]) => Promise<boolean>;
|
||||
/**
|
||||
* `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<boolean>;
|
||||
moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string, destLocalAccountId?: string | null, rows?: Email[], receipts?: CrossAccountMoveReceipt[]) => Promise<boolean>;
|
||||
/**
|
||||
* 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<boolean>;
|
||||
/** Undo for a move: put the messages back (restoreBatchesFor) and reload the list and counts. */
|
||||
restoreEmails: (client: IJMAPClient, batches: EmailRestoreBatch[]) => Promise<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* 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<boolean>;
|
||||
/**
|
||||
* 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<boolean>;
|
||||
/** `receipts` collects what went to another account, for Undo. */
|
||||
batchMoveToMailbox: (client: IJMAPClient, mailboxId: string, target?: BatchActionTarget, receipts?: CrossAccountMoveReceipt[]) => Promise<boolean>;
|
||||
batchArchive: (client: IJMAPClient) => Promise<boolean>;
|
||||
|
||||
// Spam operations
|
||||
@@ -2280,6 +2314,7 @@ async function moveSourceBatches<T extends SourceStamped>(
|
||||
batches: SourceBatch<T>[],
|
||||
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<T extends SourceStamped>(
|
||||
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<T extends SourceStamped>(
|
||||
* 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<BatchFailure[]> {
|
||||
const { moved, failures } = await moveSourceBatches(groupBySource(rows), destination, passedClient);
|
||||
async function moveRowsToMailbox(rows: Email[], destination: MoveDestination, passedClient: IJMAPClient, receipts?: CrossAccountMoveReceipt[]): Promise<BatchFailure[]> {
|
||||
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<EmailStore>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
moveToMailboxCrossAware: async (client, emailId, destinationMailboxId, emailKey): Promise<boolean> => {
|
||||
moveToMailboxCrossAware: async (client, emailId, destinationMailboxId, emailKey, receipts): Promise<boolean> => {
|
||||
const isTarget = singleEmailMatcher(emailId, emailKey);
|
||||
const state = get();
|
||||
const email = state.emails.find(isTarget);
|
||||
@@ -4452,7 +4489,7 @@ export const useEmailStore = create<EmailStore>((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<EmailStore>((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<EmailStore>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
moveEmailsToMailbox: async (client, emailIds, destinationMailboxId, destLocalAccountId, rows): Promise<boolean> => {
|
||||
moveEmailsToMailbox: async (client, emailIds, destinationMailboxId, destLocalAccountId, rows, receipts): Promise<boolean> => {
|
||||
if (emailIds.length === 0) return true;
|
||||
const idSet = new Set(emailIds);
|
||||
|
||||
@@ -4578,6 +4616,7 @@ export const useEmailStore = create<EmailStore>((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<EmailStore>((set, get) => ({
|
||||
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId, sourceJmapAccountId, options): Promise<boolean> => {
|
||||
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<EmailStore>((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<EmailStore>((set, get) => ({
|
||||
// lib/jmap/server-keywords.ts).
|
||||
const keywords: Record<string, boolean> = { ...(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<EmailStore>((set, get) => ({
|
||||
void get().fetchMailboxes(client);
|
||||
},
|
||||
|
||||
undoMove: async (client, batches, receipts): Promise<void> => {
|
||||
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<string, { receipt: CrossAccountMoveReceipt; folder: string; items: Array<{ receipt: CrossAccountMoveReceipt; point: EmailRestorePoint }> }>();
|
||||
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<EmailStore>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
batchMoveToMailbox: async (client, toMailboxId, target): Promise<boolean> => {
|
||||
batchMoveToMailbox: async (client, toMailboxId, target, receipts): Promise<boolean> => {
|
||||
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<EmailStore>((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 });
|
||||
|
||||
Reference in New Issue
Block a user