"use client"; import { useCallback, useState, DragEvent } from "react"; 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; } /** * Returns the local accountId ("user@host") that owns the destination * mailbox. `mailbox.accountId` is the JMAP server's opaque account id, but * `clients`, `activeAccountId`, and `email.accountId` all live in the local * namespace. We map back by matching the JMAP id against each connected * client's `getAccountId()`. Falls back to the viewing/active account so * single-account flows (no connected clients map entry yet, in-memory edits, * etc.) still resolve correctly. */ function resolveDestAccountId(mailbox: Mailbox): string | null { const jmapId = mailbox.accountId; if (jmapId) { const clients = useAuthStore.getState().getAllConnectedClients(); for (const [localId, client] of clients) { if (client.getAccountId() === jmapId) return localId; } } return useEmailStore.getState().viewingAccountId ?? useAuthStore.getState().activeAccountId; } interface UseMailboxDropOptions { mailbox: Mailbox; onDropComplete?: () => void; // Translation callbacks for toast messages onSuccess?: (count: number, mailboxName: string) => void; onError?: (error: string) => void; } interface UseMailboxDropReturn { dropHandlers: { onDragOver: (e: DragEvent) => void; onDragEnter: (e: DragEvent) => void; onDragLeave: (e: DragEvent) => void; onDrop: (e: DragEvent) => void; }; isDropTarget: boolean; isValidDropTarget: boolean; isInvalidDropTarget: boolean; } export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn { const [isOver, setIsOver] = useState(false); const { client } = useAuthStore(); const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(); const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext(); // Determine if this is a valid drop target const isValidTarget = useCallback(() => { if (!isDragging) return false; // Cannot drop on same mailbox if (mailbox.id === sourceMailboxId) return false; // Check if mailbox accepts items if (!mailbox.myRights?.mayAddItems) return false; // Virtual nodes (shared folder headers) cannot be drop targets if (mailbox.id.startsWith("shared-")) return false; return true; }, [isDragging, mailbox, sourceMailboxId]); const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); if (isValidTarget()) { e.dataTransfer.dropEffect = "move"; } else { e.dataTransfer.dropEffect = "none"; } }, [isValidTarget]); const handleDragEnter = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsOver(true); }, []); const handleDragLeave = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); // Only leave if actually leaving the element (not entering a child) const relatedTarget = e.relatedTarget as Node | null; if (!e.currentTarget.contains(relatedTarget)) { setIsOver(false); } }, []); const handleDrop = useCallback(async (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsOver(false); if (!client || !isValidTarget()) { endDrag(); return; } try { const emailIdsJson = e.dataTransfer.getData("application/x-email-ids"); if (!emailIdsJson) { endDrag(); return; } const emailIds: string[] = JSON.parse(emailIdsJson); // 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 = resolveDestAccountId(mailbox); 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); } // Decide whether to route via the cross-account (blob copy + import) // pipeline. Two cases require it: // 1. Destination is a delegated/shared mailbox whose owner JMAP // account differs from the source mailbox's JMAP account. There's // no atomic Email/set across accounts, even via the same client. // 2. Destination is a primary mailbox on a different connected local // account than the source — the historical multi-account case. const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId); const sourceJmapAccountId = sourceMb?.accountId; const destJmapAccountId = mailbox.accountId; const sourceAccountIds = Array.from(bySource.keys()); const isJmapCrossAccount = !!sourceJmapAccountId && !!destJmapAccountId && sourceJmapAccountId !== destJmapAccountId; const isLocalCrossAccount = !mailbox.isShared && !!destAccountId && sourceAccountIds.some((src) => src !== destAccountId); const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount; if (isCrossAccount) { if (!destAccountId) { throw new Error('Could not resolve destination account'); } // For a shared destination there is no separately-connected client // for the owner; we reuse the viewing user's client but tell the // import call to target the owner's JMAP account. const jmapDestId = mailbox.originalId || mailbox.id; const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined; // Mirror image for the source: a shared group mailbox is accessed // 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; await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride, sourceJmapOverride); } 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). // 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); if (onSuccess) { onSuccess(emailIds.length, mailboxPath); } else { if (emailIds.length === 1) { toast.success("Email moved", `Moved to ${mailboxPath}`); } else { toast.success("Emails moved", `${emailIds.length} emails moved to ${mailboxPath}`); } } onDropComplete?.(); } catch (error) { console.error("Failed to move emails:", error); // Call error callback if provided, otherwise use fallback if (onError) { onError(error instanceof Error ? error.message : 'Unknown error'); } else { // Fallback for backward compatibility toast.error("Move failed", "Could not move emails to the selected folder"); } } finally { endDrag(); } }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]); const valid = isValidTarget(); return { dropHandlers: { onDragOver: handleDragOver, onDragEnter: handleDragEnter, onDragLeave: handleDragLeave, onDrop: handleDrop, }, isDropTarget: isOver && isDragging, isValidDropTarget: isOver && valid, isInvalidDropTarget: isOver && isDragging && !valid, }; }