feat: allow drag-and-drop into shared mailboxes

This commit is contained in:
Linus Rath
2026-05-25 18:53:54 +02:00
parent f8e7cce85a
commit 3a8daf8bff
4 changed files with 52 additions and 29 deletions
+27 -19
View File
@@ -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);
+1 -1
View File
@@ -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
View File
@@ -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,
+7 -2
View File
@@ -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;
}), }),