feat: support cross-account email moves in Pro shell

This commit is contained in:
Linus Rath
2026-05-21 17:22:31 +02:00
parent 1756f5ac1c
commit 280f5bc675
2 changed files with 176 additions and 13 deletions
+58 -13
View File
@@ -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<string, string[]>();
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();
+118
View File
@@ -118,6 +118,20 @@ interface EmailStore {
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>;
/**
* 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<string, string[]>,
destAccountId: string,
destMailboxId: string,
) => Promise<void>;
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
advancedSearch: (client: IJMAPClient) => Promise<void>;
setSearchFilters: (filters: Partial<SearchFilters>) => void;
@@ -1077,6 +1091,110 @@ export const useEmailStore = create<EmailStore>((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<string, boolean> = { ...(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<string>([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();