fix: move mail from shared group inbox to personal inbox #375
This commit is contained in:
@@ -173,7 +173,11 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
|||||||
// import call to target the owner's JMAP account.
|
// import call to target the owner's JMAP account.
|
||||||
const jmapDestId = mailbox.originalId || mailbox.id;
|
const jmapDestId = mailbox.originalId || mailbox.id;
|
||||||
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
|
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 {
|
} else {
|
||||||
// Single-account or same-account-shared move: bulk JMAP request.
|
// Single-account or same-account-shared move: bulk JMAP request.
|
||||||
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export interface IJMAPClient {
|
|||||||
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
|
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
|
||||||
setKeyword(emailId: string, keyword: string): Promise<void>;
|
setKeyword(emailId: string, keyword: string): Promise<void>;
|
||||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
||||||
deleteEmail(emailId: string): Promise<void>;
|
deleteEmail(emailId: string, accountId?: string): Promise<void>;
|
||||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||||
@@ -210,8 +210,8 @@ export interface IJMAPClient {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
},
|
},
|
||||||
): Promise<{ blobId: string; size: number; type: string }>;
|
): Promise<{ blobId: string; size: number; type: string }>;
|
||||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string;
|
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string;
|
||||||
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>;
|
fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob>;
|
||||||
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
|
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
|
||||||
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
|
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
|
||||||
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
|
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
|
||||||
|
|||||||
+9
-7
@@ -1271,10 +1271,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return allIds.length;
|
return allIds.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteEmail(emailId: string): Promise<void> {
|
async deleteEmail(emailId: string, accountId?: string): Promise<void> {
|
||||||
await this.request([
|
await this.request([
|
||||||
["Email/set", {
|
["Email/set", {
|
||||||
accountId: this.accountId,
|
accountId: accountId || this.accountId,
|
||||||
destroy: [emailId],
|
destroy: [emailId],
|
||||||
}, "0"],
|
}, "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) {
|
if (!this.downloadUrl) {
|
||||||
throw new Error('Download URL not available. Please reconnect.');
|
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
|
return this.downloadUrl
|
||||||
.replace('{accountId}', encodeURIComponent(this.accountId))
|
.replace('{accountId}', encodeURIComponent(accountId || this.accountId))
|
||||||
.replace('{blobId}', encodeURIComponent(blobId))
|
.replace('{blobId}', encodeURIComponent(blobId))
|
||||||
.replace('{name}', encodeURIComponent(name || 'download'))
|
.replace('{name}', encodeURIComponent(name || 'download'))
|
||||||
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob> {
|
async fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob> {
|
||||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
||||||
const response = await this.authenticatedFetch(url, {});
|
const response = await this.authenticatedFetch(url, {});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||||
|
|||||||
+12
-4
@@ -152,12 +152,17 @@ interface EmailStore {
|
|||||||
* for the import — used when dropping into a delegated/shared mailbox that
|
* for the import — used when dropping into a delegated/shared mailbox that
|
||||||
* is owned by a different JMAP account but accessed through the same
|
* is owned by a different JMAP account but accessed through the same
|
||||||
* client (i.e. there is no separate connected client for the owner).
|
* 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: (
|
crossAccountMoveEmails: (
|
||||||
emailIdsBySource: Map<string, string[]>,
|
emailIdsBySource: Map<string, string[]>,
|
||||||
destAccountId: string,
|
destAccountId: string,
|
||||||
destMailboxId: string,
|
destMailboxId: string,
|
||||||
destJmapAccountId?: string,
|
destJmapAccountId?: string,
|
||||||
|
sourceJmapAccountId?: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
|
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
|
||||||
advancedSearch: (client: IJMAPClient) => Promise<void>;
|
advancedSearch: (client: IJMAPClient) => Promise<void>;
|
||||||
@@ -1233,7 +1238,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId) => {
|
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId, sourceJmapAccountId) => {
|
||||||
if (emailIdsBySource.size === 0) return;
|
if (emailIdsBySource.size === 0) return;
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
@@ -1260,14 +1265,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// the source clean in the happy path.
|
// the source clean in the happy path.
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
emailIds.map(async (emailId) => {
|
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) {
|
if (!full?.blobId) {
|
||||||
throw new Error('Source email has no raw blob to copy');
|
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<string, boolean> = { ...(full.keywords ?? {}) };
|
const keywords: Record<string, boolean> = { ...(full.keywords ?? {}) };
|
||||||
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId);
|
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId);
|
||||||
await sourceClient.deleteEmail(emailId);
|
await sourceClient.deleteEmail(emailId, sourceJmapAccountId);
|
||||||
return emailId;
|
return emailId;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user