feat: allow drag-and-drop into shared mailboxes
This commit is contained in:
+27
-19
@@ -81,20 +81,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
|||||||
// Virtual nodes (shared folder headers) cannot be drop targets
|
// Virtual nodes (shared folder headers) cannot be drop targets
|
||||||
if (mailbox.id.startsWith("shared-")) return false;
|
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;
|
return true;
|
||||||
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
|
}, [isDragging, mailbox, sourceMailboxId]);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -154,18 +142,38 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
|||||||
bySource.get(srcAccountId)!.push(id);
|
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 sourceAccountIds = Array.from(bySource.keys());
|
||||||
const isCrossAccount =
|
|
||||||
!!destAccountId &&
|
const isJmapCrossAccount =
|
||||||
|
!!sourceJmapAccountId &&
|
||||||
|
!!destJmapAccountId &&
|
||||||
|
sourceJmapAccountId !== destJmapAccountId;
|
||||||
|
const isLocalCrossAccount =
|
||||||
!mailbox.isShared &&
|
!mailbox.isShared &&
|
||||||
|
!!destAccountId &&
|
||||||
sourceAccountIds.some((src) => src !== destAccountId);
|
sourceAccountIds.some((src) => src !== destAccountId);
|
||||||
|
const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount;
|
||||||
|
|
||||||
if (isCrossAccount) {
|
if (isCrossAccount) {
|
||||||
// JMAP can't natively move an email between primary accounts, so the
|
if (!destAccountId) {
|
||||||
// store reuploads each source blob into the destination account and
|
throw new Error('Could not resolve destination account');
|
||||||
// then deletes the original.
|
}
|
||||||
|
// 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 jmapDestId = mailbox.originalId || mailbox.id;
|
||||||
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId);
|
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
|
||||||
|
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride);
|
||||||
} 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);
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export interface IJMAPClient {
|
|||||||
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
||||||
|
|
||||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>): Promise<string>;
|
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
||||||
submitEmail(emailId: string, identityId: string): Promise<void>;
|
submitEmail(emailId: string, identityId: string): Promise<void>;
|
||||||
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
|
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-7
@@ -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) {
|
if (!this.session) {
|
||||||
throw new Error('Not connected. Call connect() first.');
|
throw new Error('Not connected. Call connect() first.');
|
||||||
}
|
}
|
||||||
@@ -2798,7 +2798,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
throw new Error('Upload URL not available');
|
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, {
|
const response = await this.authenticatedFetch(finalUploadUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||||
@@ -2828,7 +2829,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Nested format: { [accountId]: { blobId, type, size } }
|
// Nested format: { [accountId]: { blobId, type, size } }
|
||||||
const blobInfo = result[this.accountId];
|
const blobInfo = result[targetAccountId];
|
||||||
if (blobInfo?.blobId) {
|
if (blobInfo?.blobId) {
|
||||||
return {
|
return {
|
||||||
blobId: blobInfo.blobId,
|
blobId: blobInfo.blobId,
|
||||||
@@ -5302,20 +5303,29 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return response.arrayBuffer();
|
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(
|
async importRawEmail(
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
mailboxIds: Record<string, boolean>,
|
mailboxIds: Record<string, boolean>,
|
||||||
keywords?: Record<string, boolean>,
|
keywords?: Record<string, boolean>,
|
||||||
|
accountId?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// 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 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
|
// Then import via Email/import
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
['Email/import', {
|
['Email/import', {
|
||||||
accountId: this.accountId,
|
accountId: targetAccountId,
|
||||||
emails: {
|
emails: {
|
||||||
'smime-import': {
|
'smime-import': {
|
||||||
blobId,
|
blobId,
|
||||||
|
|||||||
@@ -126,11 +126,16 @@ interface EmailStore {
|
|||||||
* pass the active account's id explicitly (no `__default__` sentinel).
|
* pass the active account's id explicitly (no `__default__` sentinel).
|
||||||
* `destMailboxId` is the raw JMAP id on the destination server (not the
|
* `destMailboxId` is the raw JMAP id on the destination server (not the
|
||||||
* `accountId:mailboxId` namespace used for shared folders).
|
* `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: (
|
crossAccountMoveEmails: (
|
||||||
emailIdsBySource: Map<string, string[]>,
|
emailIdsBySource: Map<string, string[]>,
|
||||||
destAccountId: string,
|
destAccountId: string,
|
||||||
destMailboxId: string,
|
destMailboxId: string,
|
||||||
|
destJmapAccountId?: 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>;
|
||||||
@@ -1127,7 +1132,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId) => {
|
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId) => {
|
||||||
if (emailIdsBySource.size === 0) return;
|
if (emailIdsBySource.size === 0) return;
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
@@ -1160,7 +1165,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
const blob = await sourceClient.fetchBlob(full.blobId);
|
const blob = await sourceClient.fetchBlob(full.blobId);
|
||||||
const keywords: Record<string, boolean> = { ...(full.keywords ?? {}) };
|
const keywords: Record<string, boolean> = { ...(full.keywords ?? {}) };
|
||||||
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords);
|
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId);
|
||||||
await sourceClient.deleteEmail(emailId);
|
await sourceClient.deleteEmail(emailId);
|
||||||
return emailId;
|
return emailId;
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user