From 280f5bc67570ae60addbd1110e538480654e87e1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 17:22:31 +0200 Subject: [PATCH] feat: support cross-account email moves in Pro shell --- hooks/use-mailbox-drop.ts | 71 ++++++++++++++++++----- stores/email-store.ts | 118 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 13 deletions(-) diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 218545d0..e018bd04 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -1,13 +1,26 @@ "use client"; import { useCallback, useState, DragEvent } from "react"; -import { Mailbox } from "@/lib/jmap/types"; +import { Mailbox, Email } from "@/lib/jmap/types"; import { useEmailStore } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { toast } from "@/stores/toast-store"; import { getMailboxPath } from "@/lib/utils"; +/** + * Returns the source accountId for an email being dragged. In unified view + * each email carries its own `accountId`; otherwise everything in the view + * belongs to whichever account is currently being viewed (Pro shell's + * Thunderbird-style sidebar) or the globally-active account. + */ +function resolveSourceAccountId(email: Email | undefined): string | null { + if (email?.accountId) return email.accountId; + const viewingId = useEmailStore.getState().viewingAccountId; + if (viewingId) return viewingId; + return useAuthStore.getState().activeAccountId; +} + interface UseMailboxDropOptions { mailbox: Mailbox; onDropComplete?: () => void; @@ -31,7 +44,7 @@ interface UseMailboxDropReturn { export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn { const [isOver, setIsOver] = useState(false); const { client } = useAuthStore(); - const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(); + const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(); const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext(); // Determine if this is a valid drop target @@ -47,13 +60,13 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: // Virtual nodes (shared folder headers) cannot be drop targets if (mailbox.id.startsWith("shared-")) return false; - // For shared mailboxes, check account compatibility + // Shared (delegated) mailboxes still require the source to belong to the + // same delegating account. Real cross-account moves between primary + // accounts go through the cross-account path further down, but the + // shared-folder semantics here are about ACLs rather than transport, so + // they remain disallowed. if (mailbox.isShared && draggedEmails[0]) { - // Get the source mailbox's account ID from the store - const mailboxes = useEmailStore.getState().mailboxes; - const sourceMb = mailboxes.find(mb => mb.id === sourceMailboxId); - - // Cross-account moves are not supported + const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId); if (sourceMb?.accountId !== mailbox.accountId) { return false; } @@ -107,16 +120,48 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: const emailIds: string[] = JSON.parse(emailIdsJson); - // Move in a single bulk JMAP request (store handles counter updates). - await moveEmailsToMailbox(client, emailIds, mailbox.id); + // Group dragged emails by source account. In single-account flows this + // collapses to one bucket; in unified view or the Pro multi-account + // sidebar a single drag can mix sources. + const destAccountId = mailbox.accountId; + const idToEmail = new Map(draggedEmails.map((em) => [em.id, em])); + const bySource = new Map(); + for (const id of emailIds) { + const srcAccountId = resolveSourceAccountId(idToEmail.get(id)); + if (!srcAccountId) continue; + if (!bySource.has(srcAccountId)) bySource.set(srcAccountId, []); + bySource.get(srcAccountId)!.push(id); + } + + const sourceAccountIds = Array.from(bySource.keys()); + const isCrossAccount = + !!destAccountId && + !mailbox.isShared && + sourceAccountIds.some((src) => src !== destAccountId); + + if (isCrossAccount) { + // JMAP can't natively move an email between primary accounts, so the + // store reuploads each source blob into the destination account and + // then deletes the original. + const jmapDestId = mailbox.originalId || mailbox.id; + await crossAccountMoveEmails(bySource, destAccountId, jmapDestId); + } else { + // Single-account or same-account-shared move: bulk JMAP request. + await moveEmailsToMailbox(client, emailIds, mailbox.id); + } // Clear selection if any selected emails were moved if (emailIds.some(id => selectedEmailIds.has(id))) { clearSelection(); } - // Refresh the current mailbox view (honors active search/filters) - await refreshCurrentMailbox(client); + // Refresh the current mailbox view (honors active search/filters). + // Skip for cross-account moves: the store already dropped the moved + // rows from the in-memory list and refreshed both accounts' folder + // caches in the background. + if (!isCrossAccount) { + await refreshCurrentMailbox(client); + } const mailboxPath = getMailboxPath(mailbox, mailboxes); @@ -144,7 +189,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: } finally { endDrag(); } - }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]); + }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]); const valid = isValidTarget(); diff --git a/stores/email-store.ts b/stores/email-store.ts index ea372370..add15ed2 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -118,6 +118,20 @@ interface EmailStore { moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise; moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; + /** + * Move emails across JMAP accounts. JMAP has no native cross-account move, + * so for each email we fetch the source's raw RFC822 blob, import it into + * the destination account's target mailbox, then delete the original. + * `emailIdsBySource` maps each source accountId to the emails it owns; + * pass the active account's id explicitly (no `__default__` sentinel). + * `destMailboxId` is the raw JMAP id on the destination server (not the + * `accountId:mailboxId` namespace used for shared folders). + */ + crossAccountMoveEmails: ( + emailIdsBySource: Map, + destAccountId: string, + destMailboxId: string, + ) => Promise; searchEmails: (client: IJMAPClient, query: string) => Promise; advancedSearch: (client: IJMAPClient) => Promise; setSearchFilters: (filters: Partial) => void; @@ -1077,6 +1091,110 @@ export const useEmailStore = create((set, get) => ({ } }, + crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId) => { + if (emailIdsBySource.size === 0) return; + set({ isLoading: true, error: null }); + try { + const destClient = useAuthStore.getState().getClientForAccount(destAccountId); + if (!destClient) { + throw new Error('Destination account is not connected'); + } + + const movedIds: string[] = []; + const failures: Array<{ emailId: string; error: string }> = []; + + for (const [sourceAccountId, emailIds] of emailIdsBySource.entries()) { + const sourceClient = useAuthStore.getState().getClientForAccount(sourceAccountId); + if (!sourceClient) { + for (const emailId of emailIds) { + failures.push({ emailId, error: 'Source account not connected' }); + } + continue; + } + + // Fan the per-email copy/import/delete pipeline out in parallel. + // JMAP has no atomic cross-account move, so we accept that a crash + // mid-flight could leave a duplicate; the delete on success keeps + // the source clean in the happy path. + const results = await Promise.allSettled( + emailIds.map(async (emailId) => { + const full = await sourceClient.getEmail(emailId); + if (!full?.blobId) { + throw new Error('Source email has no raw blob to copy'); + } + const blob = await sourceClient.fetchBlob(full.blobId); + const keywords: Record = { ...(full.keywords ?? {}) }; + await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords); + await sourceClient.deleteEmail(emailId); + return emailId; + }), + ); + + results.forEach((outcome, i) => { + const emailId = emailIds[i]; + if (outcome.status === 'fulfilled') { + movedIds.push(emailId); + } else { + const err = outcome.reason; + failures.push({ + emailId, + error: err instanceof Error ? err.message : String(err), + }); + } + }); + } + + // Drop the moved emails from the current view and clear stale selection + // entries. Counter accuracy comes from the mailbox refresh below. + const movedSet = new Set(movedIds); + set((state) => ({ + emails: state.emails.filter((e) => !movedSet.has(e.id)), + selectedEmail: + state.selectedEmail && movedSet.has(state.selectedEmail.id) + ? null + : state.selectedEmail, + selectedEmailIds: (() => { + const next = new Set(state.selectedEmailIds); + for (const id of movedIds) next.delete(id); + return next; + })(), + isLoading: false, + })); + + // Refresh mailbox folder lists/counters for every account we touched. + // Background-only so the move feels instant — counters will catch up. + const activeAccountId = useAuthStore.getState().activeAccountId; + const touched = new Set([destAccountId, ...emailIdsBySource.keys()]); + for (const acctId of touched) { + const c = useAuthStore.getState().getClientForAccount(acctId); + if (!c) continue; + if (acctId === activeAccountId) { + void get().fetchMailboxes(c); + } else { + void get().fetchAccountMailboxes(c, acctId); + } + } + + if (failures.length > 0) { + const first = failures[0]; + throw new Error( + failures.length === 1 + ? `Failed to move email: ${first.error}` + : `Failed to move ${failures.length} email(s); first error: ${first.error}`, + ); + } + } catch (error) { + set({ + isLoading: false, + error: + error instanceof Error + ? error.message + : 'Failed to move emails between accounts', + }); + throw error; + } + }, + moveThreadToMailbox: async (client, emailId, destinationMailboxId) => { try { const state = get();