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:
Stefan Hildebrandt
2026-07-24 18:30:50 +02:00
parent fc116a8b2f
commit 15ad783848
7 changed files with 234 additions and 12 deletions
+3 -2
View File
@@ -282,6 +282,7 @@ export default function Home() {
toggleStar,
setEmailKeywordsLocal,
moveToMailbox,
moveToMailboxCrossAware,
moveThreadToMailbox,
searchEmails,
searchQuery,
@@ -3229,7 +3230,7 @@ export default function Home() {
}}
onMoveToMailbox={async (emailId, mailboxId) => {
if (client) {
await moveToMailbox(client, emailId, mailboxId);
await moveToMailboxCrossAware(client, emailId, mailboxId);
}
}}
onMarkAsSpam={async (email) => {
@@ -3499,7 +3500,7 @@ export default function Home() {
selectedMailbox={selectedMailbox}
onMoveToMailbox={async (mailboxId) => {
if (client && selectedEmail) {
await moveToMailbox(client, selectedEmail.id, mailboxId);
await moveToMailboxCrossAware(client, selectedEmail.id, mailboxId);
}
}}
className={isMobile ? "flex-1" : undefined}
+4 -7
View File
@@ -77,12 +77,9 @@ test.describe('Shared-folder moves', () => {
expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy();
});
// KNOWN LIMITATION (documented via test.fail): the "Move to" submenu offers a
// shared folder as a destination for an own-account message, but clicking it
// does NOT relocate the message across the account boundary — it stays put.
// Same in reverse (shared -> own). If cross-account moves get implemented,
// these will start passing; flip them back to plain tests then.
test.fail('own account -> shared folder', async ({ page }) => {
// The "Move to" submenu relocates a message across the account boundary
// (own ↔ shared folder) via copy+delete, matching drag-and-drop.
test('own account -> shared folder', async ({ page }) => {
const s = subj('mv-own2sh');
await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' });
await jc.waitForEmail(s);
@@ -100,7 +97,7 @@ test.describe('Shared-folder moves', () => {
expect(await ja.findEmailBySubject(s, teamA), 'message in shared TeamA').toBeTruthy();
});
test.fail('shared folder -> own account', async ({ page }) => {
test('shared folder -> own account', async ({ page }) => {
const s = subj('mv-sh2own');
await seedInto(teamA, s);
+1
View File
@@ -1094,6 +1094,7 @@ export class DemoJMAPClient implements IJMAPClient {
// ── S/MIME raw-email helpers ──────────────────────────────────
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
async copyEmailAcrossAccounts(): Promise<string> { return generateDemoId('email'); }
async submitEmail(): Promise<void> { /* no-op */ }
async submitRawEmail(blob: Blob,
identityId: string,
+8
View File
@@ -345,4 +345,12 @@ export interface IJMAPClient {
// ── S/MIME raw-email helpers ──────────────────────────────────
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
submitEmail(emailId: string, identityId: string): Promise<void>;
/**
* Server-side move of one email across accounts reachable through THIS client
* (JMAP `Email/copy` + destroy-original). Used for delegated/shared folders,
* where the two accounts share a client but a client can't stage a blob in a
* delegated account (so the blob copy+import path doesn't work). Returns the
* new email id in the destination account.
*/
copyEmailAcrossAccounts(emailId: string, fromAccountId: string, toAccountId: string, destMailboxId: string): Promise<string>;
}
+26
View File
@@ -6394,6 +6394,32 @@ export class JMAPClient implements IJMAPClient {
* a shared mailbox owned by another user). When omitted, falls back to the
* client's own primary account.
*/
async copyEmailAcrossAccounts(
emailId: string,
fromAccountId: string,
toAccountId: string,
destMailboxId: string,
): Promise<string> {
const response = await this.request([
["Email/copy", {
fromAccountId,
accountId: toAccountId,
create: { c: { id: emailId, mailboxIds: { [destMailboxId]: true } } },
onSuccessDestroyOriginal: true,
}, "0"],
]);
const res = response.methodResponses?.[0]?.[1];
const err = res?.notCreated?.c;
if (err) {
throw new Error(err.description || err.type || "Failed to copy email across accounts");
}
const id = res?.created?.c?.id;
if (!id) {
throw new Error("Email/copy succeeded but no ID returned");
}
return id;
}
async importRawEmail(
blob: Blob,
mailboxIds: Record<string, boolean>,
@@ -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
View File
@@ -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');