feat: implement bulk email moving functionality in email store

This commit is contained in:
Linus Rath
2026-04-22 00:10:40 +02:00
parent 3c9fa5dc25
commit b7374570c8
2 changed files with 79 additions and 9 deletions
+4 -9
View File
@@ -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();
+75
View File
@@ -77,6 +77,7 @@ interface EmailStore {
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise<void>;
moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
advancedSearch: (client: IJMAPClient) => Promise<void>;
@@ -803,6 +804,80 @@ export const useEmailStore = create<EmailStore>((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<string, string[]>();
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<string>();
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();