From b7374570c832d87a69102731bece99045cd7cd27 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:10:40 +0200 Subject: [PATCH] feat: implement bulk email moving functionality in email store --- hooks/use-mailbox-drop.ts | 13 +++---- stores/email-store.ts | 75 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 551e5f60..6a9dc0e9 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -30,7 +30,7 @@ interface UseMailboxDropReturn { export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn { const [isOver, setIsOver] = useState(false); const { client } = useAuthStore(); - const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore(); + const { moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore(); const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext(); // Determine if this is a valid drop target @@ -106,13 +106,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: const emailIds: string[] = JSON.parse(emailIdsJson); - // Get the destination mailbox ID (use originalId for shared folders) - const destinationId = mailbox.originalId || mailbox.id; - - // Move emails one by one (store handles counter updates) - for (const emailId of emailIds) { - await moveToMailbox(client, emailId, destinationId); - } + // Move in a single bulk JMAP request (store handles counter updates). + await moveEmailsToMailbox(client, emailIds, mailbox.id); // Clear selection if any selected emails were moved if (emailIds.some(id => selectedEmailIds.has(id))) { @@ -148,7 +143,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: } finally { endDrag(); } - }, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]); + }, [client, mailbox, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]); const valid = isValidTarget(); diff --git a/stores/email-store.ts b/stores/email-store.ts index 4de96988..259bbd9c 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -77,6 +77,7 @@ interface EmailStore { deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise; moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; + moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise; moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; searchEmails: (client: IJMAPClient, query: string) => Promise; advancedSearch: (client: IJMAPClient) => Promise; @@ -803,6 +804,80 @@ export const useEmailStore = create((set, get) => ({ } }, + moveEmailsToMailbox: async (client, emailIds, destinationMailboxId) => { + if (emailIds.length === 0) return; + if (emailIds.length === 1) { + await get().moveToMailbox(client, emailIds[0], destinationMailboxId); + return; + } + + try { + const { emails, mailboxes, selectedMailbox, isUnifiedView } = get(); + const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); + const jmapDestId = destMailbox?.originalId || destinationMailboxId; + const idSet = new Set(emailIds); + const affected = emails.filter(e => idSet.has(e.id)); + + if (isUnifiedView) { + // In unified view, emails may span accounts — group and dispatch per-account. + const byAccount = new Map(); + for (const e of affected) { + const acct = e.accountId || '__default__'; + if (!byAccount.has(acct)) byAccount.set(acct, []); + byAccount.get(acct)!.push(e.id); + } + await Promise.all(Array.from(byAccount.entries()).map(async ([acct, ids]) => { + const acctClient = acct === '__default__' ? client : useAuthStore.getState().getClientForAccount(acct); + if (!acctClient) return; + await acctClient.batchMoveEmails(ids, jmapDestId); + })); + } else { + const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox); + const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; + await client.batchMoveEmails(emailIds, jmapDestId, accountId); + } + + // Adjust counters and drop moved emails from the current view. + let unreadDelta = 0; + const sourceMailboxIds = new Set(); + for (const e of affected) { + if (!e.keywords?.$seen) unreadDelta += 1; + if (e.mailboxIds) for (const mid of Object.keys(e.mailboxIds)) sourceMailboxIds.add(mid); + } + const movedCount = affected.length; + + set((state) => ({ + emails: state.emails.filter(e => !idSet.has(e.id)), + selectedEmail: state.selectedEmail && idSet.has(state.selectedEmail.id) ? null : state.selectedEmail, + selectedEmailIds: (() => { + const next = new Set(state.selectedEmailIds); + for (const id of idSet) next.delete(id); + return next; + })(), + mailboxes: state.mailboxes.map(mb => { + if (sourceMailboxIds.has(mb.id)) { + return { + ...mb, + totalEmails: Math.max(0, mb.totalEmails - movedCount), + unreadEmails: Math.max(0, mb.unreadEmails - unreadDelta), + }; + } + if (mb.id === destinationMailboxId) { + return { + ...mb, + totalEmails: mb.totalEmails + movedCount, + unreadEmails: mb.unreadEmails + unreadDelta, + }; + } + return mb; + }), + })); + } catch (error) { + set({ error: error instanceof Error ? error.message : 'Failed to move emails' }); + throw error; + } + }, + moveThreadToMailbox: async (client, emailId, destinationMailboxId) => { try { const state = get();