diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 39bd7608..6ccf363e 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -173,7 +173,11 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: // import call to target the owner's JMAP account. const jmapDestId = mailbox.originalId || mailbox.id; const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined; - await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride); + // 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); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 120717bd..f657cca9 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -97,7 +97,7 @@ export interface IJMAPClient { updateEmailKeywords(emailId: string, keywords: Record): Promise; setKeyword(emailId: string, keyword: string): Promise; migrateKeyword(oldKeyword: string, newKeyword: string): Promise; - deleteEmail(emailId: string): Promise; + deleteEmail(emailId: string, accountId?: string): Promise; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; batchDeleteEmails(emailIds: string[]): Promise; batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; @@ -210,8 +210,8 @@ export interface IJMAPClient { signal?: AbortSignal; }, ): Promise<{ blobId: string; size: number; type: string }>; - getBlobDownloadUrl(blobId: string, name?: string, type?: string): string; - fetchBlob(blobId: string, name?: string, type?: string): Promise; + getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string; + fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise; fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise; fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise; downloadBlob(blobId: string, name?: string, type?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index da36121c..87a92416 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1271,10 +1271,10 @@ export class JMAPClient implements IJMAPClient { return allIds.length; } - async deleteEmail(emailId: string): Promise { + async deleteEmail(emailId: string, accountId?: string): Promise { await this.request([ ["Email/set", { - accountId: this.accountId, + accountId: accountId || this.accountId, destroy: [emailId], }, "0"], ]); @@ -3117,21 +3117,23 @@ export class JMAPClient implements IJMAPClient { } } - getBlobDownloadUrl(blobId: string, name?: string, type?: string): string { + getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string { if (!this.downloadUrl) { throw new Error('Download URL not available. Please reconnect.'); } - // RFC 6570 level 1 URI template expansion + // RFC 6570 level 1 URI template expansion. Blobs are scoped per account, + // so a caller fetching a blob from a delegated/shared account must pass + // that owner's accountId rather than defaulting to the primary one. return this.downloadUrl - .replace('{accountId}', encodeURIComponent(this.accountId)) + .replace('{accountId}', encodeURIComponent(accountId || this.accountId)) .replace('{blobId}', encodeURIComponent(blobId)) .replace('{name}', encodeURIComponent(name || 'download')) .replace('{type}', encodeURIComponent(type || 'application/octet-stream')); } - async fetchBlob(blobId: string, name?: string, type?: string): Promise { - const url = this.getBlobDownloadUrl(blobId, name, type); + async fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise { + const url = this.getBlobDownloadUrl(blobId, name, type, accountId); const response = await this.authenticatedFetch(url, {}); if (!response.ok) { throw new Error(`Failed to fetch blob: ${response.status}`); diff --git a/stores/email-store.ts b/stores/email-store.ts index 4da028a9..e19895c4 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -152,12 +152,17 @@ interface EmailStore { * 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). + * `sourceJmapAccountId` is the mirror image for the source side: when the + * emails live in a delegated/shared mailbox accessed through the source + * client, the copy/delete must target the owner's JMAP account rather than + * the source client's primary one. */ crossAccountMoveEmails: ( emailIdsBySource: Map, destAccountId: string, destMailboxId: string, destJmapAccountId?: string, + sourceJmapAccountId?: string, ) => Promise; searchEmails: (client: IJMAPClient, query: string) => Promise; advancedSearch: (client: IJMAPClient) => Promise; @@ -1233,7 +1238,7 @@ export const useEmailStore = create((set, get) => ({ } }, - crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId) => { + crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId, sourceJmapAccountId) => { if (emailIdsBySource.size === 0) return; set({ isLoading: true, error: null }); try { @@ -1260,14 +1265,17 @@ export const useEmailStore = create((set, get) => ({ // the source clean in the happy path. const results = await Promise.allSettled( emailIds.map(async (emailId) => { - const full = await sourceClient.getEmail(emailId); + // When the source is a delegated/shared mailbox, the email, + // its blob, and the destroy all live in the owner's JMAP + // account, not the source client's primary one. + const full = await sourceClient.getEmail(emailId, sourceJmapAccountId); if (!full?.blobId) { throw new Error('Source email has no raw blob to copy'); } - const blob = await sourceClient.fetchBlob(full.blobId); + const blob = await sourceClient.fetchBlob(full.blobId, undefined, undefined, sourceJmapAccountId); const keywords: Record = { ...(full.keywords ?? {}) }; await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId); - await sourceClient.deleteEmail(emailId); + await sourceClient.deleteEmail(emailId, sourceJmapAccountId); return emailId; }), );