fix(email): make the "Move to" context menu work across accounts
Moving a message to a folder in another account (own ↔ delegated/shared) via the "Move to" context menu was a no-op — the handlers always issued a single-account Email/set, which can't move between JMAP accounts. Drag-and-drop already routed these correctly; the context menu never did. Add moveToMailboxCrossAware: it detects a cross-account destination (own and shared mailboxes both carry accountId) and routes through the drag-and-drop crossAccountMoveEmails pipeline, else falls back to the single-account move. Fix the pipeline for delegated folders too: a client can't stage a blob in a delegated account (Blob/upload → blobNotFound), so importing into a shared folder failed. When one client reaches both accounts, use a server-side JMAP Email/copy (+ destroy original) instead of blob copy+import; the blob path is kept only for separate cross-server login accounts. Adds client.copyEmailAcrossAccounts. Unit tests for the dispatch; the two 08-shared-moves specs are un-pinned. Full docker integration suite green (37 passed).
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
type Store = ReturnType<typeof useEmailStore.getState>;
|
||||
|
||||
function makeMailbox(overrides: Partial<Mailbox>): Mailbox {
|
||||
return {
|
||||
id: 'inbox',
|
||||
name: 'Inbox',
|
||||
sortOrder: 0,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: {
|
||||
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
|
||||
maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true,
|
||||
},
|
||||
isSubscribed: true,
|
||||
isShared: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEmail(id: string, mailboxServerId: string): Email {
|
||||
return {
|
||||
id, threadId: `t-${id}`, mailboxIds: { [mailboxServerId]: true }, keywords: {},
|
||||
size: 100, receivedAt: new Date().toISOString(),
|
||||
from: [{ name: 'X', email: 'x@example.com' }], to: [{ name: 'Y', email: 'y@example.com' }],
|
||||
subject: id, preview: '', hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Own account (JMAP acct "jmap-A", reached via local account "local-A") and a
|
||||
// delegated/shared folder owned by another JMAP account ("jmap-B").
|
||||
const ownInbox = makeMailbox({ id: 'inbox-A', role: 'inbox', accountId: 'jmap-A', originalId: 'srv-inbox-A' });
|
||||
const ownArchive = makeMailbox({ id: 'archive-A', role: 'archive', accountId: 'jmap-A', originalId: 'srv-archive-A' });
|
||||
const sharedTeamA = makeMailbox({ id: 'jmap-B:srv-teamA', name: 'TeamA', accountId: 'jmap-B', originalId: 'srv-teamA', isShared: true });
|
||||
|
||||
describe('email-store moveToMailboxCrossAware', () => {
|
||||
let crossSpy: ReturnType<typeof vi.fn>;
|
||||
let moveSpy: ReturnType<typeof vi.fn>;
|
||||
const client = {} as IJMAPClient;
|
||||
|
||||
beforeEach(() => {
|
||||
const email = makeEmail('e1', 'srv-inbox-A');
|
||||
email.accountId = 'local-A';
|
||||
crossSpy = vi.fn().mockResolvedValue(undefined);
|
||||
moveSpy = vi.fn().mockResolvedValue(undefined);
|
||||
useEmailStore.setState({
|
||||
emails: [email],
|
||||
mailboxes: [ownInbox, ownArchive, sharedTeamA],
|
||||
selectedMailbox: 'inbox-A',
|
||||
viewingAccountId: 'local-A',
|
||||
isUnifiedView: false,
|
||||
accountMailboxes: {},
|
||||
crossAccountMoveEmails: crossSpy as unknown as Store['crossAccountMoveEmails'],
|
||||
moveToMailbox: moveSpy as unknown as Store['moveToMailbox'],
|
||||
});
|
||||
});
|
||||
|
||||
it('routes an own → shared (cross-account) move through crossAccountMoveEmails', async () => {
|
||||
await useEmailStore.getState().moveToMailboxCrossAware(client, 'e1', 'jmap-B:srv-teamA');
|
||||
|
||||
expect(moveSpy).not.toHaveBeenCalled();
|
||||
// copy into the owner's (jmap-B) TeamA via the viewer's client, using the
|
||||
// destination's raw server id; source is own, so no source override.
|
||||
expect(crossSpy).toHaveBeenCalledWith(
|
||||
new Map([['local-A', ['e1']]]),
|
||||
'local-A',
|
||||
'srv-teamA',
|
||||
'jmap-B',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('routes a same-account move through the single-account moveToMailbox', async () => {
|
||||
await useEmailStore.getState().moveToMailboxCrossAware(client, 'e1', 'archive-A');
|
||||
|
||||
expect(crossSpy).not.toHaveBeenCalled();
|
||||
expect(moveSpy).toHaveBeenCalledWith(client, 'e1', 'archive-A');
|
||||
});
|
||||
|
||||
it('reverse: shared → own also routes cross-account (source override set)', async () => {
|
||||
const email = makeEmail('e2', 'srv-teamA');
|
||||
email.accountId = 'local-A';
|
||||
useEmailStore.setState({ emails: [email], selectedMailbox: 'jmap-B:srv-teamA' });
|
||||
|
||||
await useEmailStore.getState().moveToMailboxCrossAware(client, 'e2', 'inbox-A');
|
||||
|
||||
expect(moveSpy).not.toHaveBeenCalled();
|
||||
expect(crossSpy).toHaveBeenCalledWith(
|
||||
new Map([['local-A', ['e2']]]),
|
||||
'local-A',
|
||||
'srv-inbox-A',
|
||||
undefined, // dest (own) not shared
|
||||
'jmap-B', // source shared → override to owner account
|
||||
);
|
||||
});
|
||||
});
|
||||
+90
-3
@@ -161,6 +161,14 @@ interface EmailStore {
|
||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||
/**
|
||||
* Move a single email, routing across the account boundary when the
|
||||
* destination folder is owned by a different JMAP account (a delegated/shared
|
||||
* mailbox, or a different connected account) — the "Move to" context-menu
|
||||
* equivalent of what drag-and-drop already does. Falls back to the plain
|
||||
* single-account `moveToMailbox` when source and destination share an account.
|
||||
*/
|
||||
moveToMailboxCrossAware: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||
moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise<void>;
|
||||
moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||
/**
|
||||
@@ -424,6 +432,23 @@ function resolveEmailActionContext(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Local account id ("user@host") whose connected client owns `mailbox`.
|
||||
* `mailbox.accountId` is the JMAP server's opaque id; map it back to a local
|
||||
* client id, falling back to the viewing/active account — a delegated/shared
|
||||
* folder has no separately-connected client, it's reached through the viewer's.
|
||||
* Mirrors resolveDestAccountId in use-mailbox-drop.ts.
|
||||
*/
|
||||
function resolveDestLocalAccountId(mailbox: Mailbox): string | null {
|
||||
const jmapId = mailbox.accountId;
|
||||
if (jmapId) {
|
||||
for (const [localId, client] of useAuthStore.getState().getAllConnectedClients()) {
|
||||
if (client.getAccountId() === jmapId) return localId;
|
||||
}
|
||||
}
|
||||
return useEmailStore.getState().viewingAccountId ?? useAuthStore.getState().activeAccountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `UnifiedAccountClient[]` list used by every unified fan-out
|
||||
* action (browse, load-more, search). Each entry has a JMAP client plus a
|
||||
@@ -1595,6 +1620,54 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
moveToMailboxCrossAware: async (client, emailId, destinationMailboxId) => {
|
||||
const state = get();
|
||||
const email = state.emails.find((e) => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const { mailboxes } = resolveEmailActionContext(email, client);
|
||||
const find = (id: string) =>
|
||||
mailboxes.find((mb) => mb.id === id) ?? state.mailboxes.find((mb) => mb.id === id);
|
||||
const destMailbox = find(destinationMailboxId);
|
||||
// A context-menu move acts on the visible list, so the source folder is the
|
||||
// one currently open.
|
||||
const sourceMailbox = find(state.selectedMailbox ?? '');
|
||||
|
||||
// Cross-account when the two folders live in different JMAP accounts (both
|
||||
// own and shared mailboxes carry accountId, so this catches own↔shared too).
|
||||
const isCrossAccount =
|
||||
!!destMailbox &&
|
||||
!!sourceMailbox?.accountId &&
|
||||
!!destMailbox.accountId &&
|
||||
sourceMailbox.accountId !== destMailbox.accountId;
|
||||
|
||||
if (!isCrossAccount) {
|
||||
await get().moveToMailbox(client, emailId, destinationMailboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
const destAccountId = resolveDestLocalAccountId(destMailbox!);
|
||||
const sourceAccountId =
|
||||
email.accountId ?? state.viewingAccountId ?? useAuthStore.getState().activeAccountId;
|
||||
if (!destAccountId || !sourceAccountId) {
|
||||
// Can't resolve the local endpoints — fall back rather than drop the mail.
|
||||
await get().moveToMailbox(client, emailId, destinationMailboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
// JMAP has no cross-account move: copy the raw message into the destination
|
||||
// account's mailbox, then delete the original (crossAccountMoveEmails). The
|
||||
// *Jmap* overrides target the owner account when a shared folder is reached
|
||||
// through another user's client.
|
||||
await get().crossAccountMoveEmails(
|
||||
new Map([[sourceAccountId, [emailId]]]),
|
||||
destAccountId,
|
||||
destMailbox!.originalId ?? destMailbox!.id,
|
||||
destMailbox!.isShared ? destMailbox!.accountId : undefined,
|
||||
sourceMailbox?.isShared ? sourceMailbox.accountId : undefined,
|
||||
);
|
||||
},
|
||||
|
||||
moveToMailbox: async (client, emailId, destinationMailboxId) => {
|
||||
try {
|
||||
const email = get().emails.find(e => e.id === emailId);
|
||||
@@ -1763,9 +1836,23 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// the source clean in the happy path.
|
||||
const results = await Promise.allSettled(
|
||||
emailIds.map(async (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.
|
||||
// Delegated/shared folders: one client reaches both accounts, so a
|
||||
// server-side Email/copy moves the message. A client can't stage a
|
||||
// blob in a *delegated* account (blobNotFound), so the blob
|
||||
// copy+import path below is only valid across separate login
|
||||
// clients/servers.
|
||||
if (sourceClient === destClient) {
|
||||
await sourceClient.copyEmailAcrossAccounts(
|
||||
emailId,
|
||||
sourceJmapAccountId ?? sourceClient.getAccountId(),
|
||||
destJmapAccountId ?? destClient.getAccountId(),
|
||||
destMailboxId,
|
||||
);
|
||||
return emailId;
|
||||
}
|
||||
// Separate clients (cross-server multi-account): 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');
|
||||
|
||||
Reference in New Issue
Block a user