From 3a8daf8bff9d9a562f5e25eb0aece76a9e6508c0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 25 May 2026 18:53:54 +0200 Subject: [PATCH] feat: allow drag-and-drop into shared mailboxes --- hooks/use-mailbox-drop.ts | 46 +++++++++++++++++++++--------------- lib/jmap/client-interface.ts | 2 +- lib/jmap/client.ts | 24 +++++++++++++------ stores/email-store.ts | 9 +++++-- 4 files changed, 52 insertions(+), 29 deletions(-) diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 32026c7f..39bd7608 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -81,20 +81,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: // Virtual nodes (shared folder headers) cannot be drop targets if (mailbox.id.startsWith("shared-")) return false; - // 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]) { - const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId); - if (sourceMb?.accountId !== mailbox.accountId) { - return false; - } - } - return true; - }, [isDragging, mailbox, sourceMailboxId, draggedEmails]); + }, [isDragging, mailbox, sourceMailboxId]); const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); @@ -154,18 +142,38 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: 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 isCrossAccount = - !!destAccountId && + + const isJmapCrossAccount = + !!sourceJmapAccountId && + !!destJmapAccountId && + sourceJmapAccountId !== destJmapAccountId; + const isLocalCrossAccount = !mailbox.isShared && + !!destAccountId && sourceAccountIds.some((src) => src !== destAccountId); + const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount; 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. + 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; - await crossAccountMoveEmails(bySource, destAccountId, jmapDestId); + const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined; + await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride); } else { // Single-account or same-account-shared move: bulk JMAP request. await moveEmailsToMailbox(client, emailIds, mailbox.id); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 5ec9ec0e..1cfc8b56 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -274,7 +274,7 @@ export interface IJMAPClient { copyFileNode(id: string, newName: string, parentId: string | null): Promise; // ── S/MIME raw-email helpers ────────────────────────────────── - importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record): Promise; + importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record, accountId?: string): Promise; submitEmail(emailId: string, identityId: string): Promise; sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index ced1c366..03524cca 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2788,7 +2788,7 @@ export class JMAPClient implements IJMAPClient { } } - async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> { + async uploadBlob(file: File, accountId?: string): Promise<{ blobId: string; size: number; type: string }> { if (!this.session) { throw new Error('Not connected. Call connect() first.'); } @@ -2798,7 +2798,8 @@ export class JMAPClient implements IJMAPClient { throw new Error('Upload URL not available'); } - const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(this.accountId)); + const targetAccountId = accountId || this.accountId; + const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId)); const response = await this.authenticatedFetch(finalUploadUrl, { method: 'POST', headers: { 'Content-Type': file.type || 'application/octet-stream' }, @@ -2828,7 +2829,7 @@ export class JMAPClient implements IJMAPClient { } // Nested format: { [accountId]: { blobId, type, size } } - const blobInfo = result[this.accountId]; + const blobInfo = result[targetAccountId]; if (blobInfo?.blobId) { return { blobId: blobInfo.blobId, @@ -5302,20 +5303,29 @@ export class JMAPClient implements IJMAPClient { return response.arrayBuffer(); } - /** Import a raw MIME message blob into the account. */ + /** + * Import a raw MIME message blob into the account. Pass `accountId` to + * target a delegated account the caller has rights on (e.g. importing into + * a shared mailbox owned by another user). When omitted, falls back to the + * client's own primary account. + */ async importRawEmail( blob: Blob, mailboxIds: Record, keywords?: Record, + accountId?: string, ): Promise { - // First upload the blob + const targetAccountId = accountId || this.accountId; + // First upload the blob. Blob uploads are scoped to an account too — + // when importing into a delegated account, upload there so the resulting + // blobId is visible to Email/import on that account. const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); - const { blobId } = await this.uploadBlob(file); + const { blobId } = await this.uploadBlob(file, targetAccountId); // Then import via Email/import const response = await this.request([ ['Email/import', { - accountId: this.accountId, + accountId: targetAccountId, emails: { 'smime-import': { blobId, diff --git a/stores/email-store.ts b/stores/email-store.ts index 44ee2d00..4e7d7a86 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -126,11 +126,16 @@ interface EmailStore { * 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). + * `destJmapAccountId` overrides the destination client's primary account + * for the import — used when dropping into a delegated/shared mailbox that + * is owned by a different JMAP account but accessed through the same + * client (i.e. there is no separate connected client for the owner). */ crossAccountMoveEmails: ( emailIdsBySource: Map, destAccountId: string, destMailboxId: string, + destJmapAccountId?: string, ) => Promise; searchEmails: (client: IJMAPClient, query: string) => Promise; advancedSearch: (client: IJMAPClient) => Promise; @@ -1127,7 +1132,7 @@ export const useEmailStore = create((set, get) => ({ } }, - crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId) => { + crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId) => { if (emailIdsBySource.size === 0) return; set({ isLoading: true, error: null }); try { @@ -1160,7 +1165,7 @@ export const useEmailStore = create((set, get) => ({ } const blob = await sourceClient.fetchBlob(full.blobId); const keywords: Record = { ...(full.keywords ?? {}) }; - await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords); + await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId); await sourceClient.deleteEmail(emailId); return emailId; }),