diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 547b66dc..7345bb80 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -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} diff --git a/integration/tests/08-shared-moves.spec.ts b/integration/tests/08-shared-moves.spec.ts index b508d795..258e1084 100644 --- a/integration/tests/08-shared-moves.spec.ts +++ b/integration/tests/08-shared-moves.spec.ts @@ -13,107 +13,154 @@ import { /** * Moving mail across the own-account / shared-folder boundary, in both - * directions, and between two shared folders. The move is driven from the list - * context menu's "Move to" submenu; the authoritative check is the server-side - * mailbox the message ends up in, with the reliably-updating (own-account) - * counters checked in the UI too. + * directions, and between two shared folders (same owner and across owners). + * The move is driven from the list context menu's "Move to" submenu; the + * authoritative check is the server-side mailbox the message ends up in. + * + * Each cross-account case asserts delivery *and* that the read state survives + * (Email/copy drops keywords unless carried). Removing the source, however, is + * currently blocked by a Stalwart bug — onSuccessDestroyOriginal destroys the + * copy's create-id instead of the source id, so the original is left behind + * (support.stalw.art #1150). Those source-removal checks are pinned test.fail + * until Stalwart ships the fix; same-account moves (Email/set) are unaffected. */ -const { alice, carol } = ACCOUNTS; +const { alice, bob, carol } = ACCOUNTS; const subj = (l: string) => `IT ${l} ${Date.now()}`; +type FolderSel = Parameters[1]; test.describe('Shared-folder moves', () => { - let ja: JmapClient; // owner + let ja: JmapClient; // owner A + let jb: JmapClient; // owner B (cross-owner shared → shared) let jc: JmapClient; // grantee let teamA: string; let teamB: string; + let teamC: string; // owned by bob test.beforeEach(async () => { ja = await JmapClient.connect(alice.email, alice.password); + jb = await JmapClient.connect(bob.email, bob.password); jc = await JmapClient.connect(carol.email, carol.password); await ja.reset(); + await jb.reset(); await jc.reset(); teamA = await ja.createSharedFolder('TeamA', carol.email); teamB = await ja.createSharedFolder('TeamB', carol.email); + teamC = await jb.createSharedFolder('TeamC', carol.email); }); - async function seedInto(mailboxId: string, subject: string, owner = ja): Promise { - const acct = owner === ja ? alice : carol; + // Seed a message into a mailbox and mark it read, so a lost $seen after the + // move is observable as the moved copy coming back unread. + async function seedRead(mailboxId: string, subject: string, owner = ja): Promise { + const acct = owner === ja ? alice : owner === jb ? bob : carol; await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' }); const m = await owner.waitForEmail(subject); await owner.moveEmail(m.id, mailboxId); + await owner.setSeen(m.id, true); } - test('shared folder A -> shared folder B', async ({ page }) => { + const seenOf = (m: any) => Boolean(m?.keywords?.$seen); + + // Log in as carol, reveal the relevant shared owners, and move `subject` from + // `source` to `dest` via the context menu. + async function uiMove( + page: import('@playwright/test').Page, + opts: { subject: string; owners: string[]; source: FolderSel; dest: FolderSel }, + ): Promise { + await login(page, carol); + for (const o of opts.owners) await expandSharedFolders(page, o); + const destId = await folderMailboxId(page, opts.dest); + await openFolder(page, opts.source); + await forceSync(page); + await moveEmailTo(page, opts.subject, destId); + await page.waitForTimeout(2000); + } + + const inbox: FolderSel = { role: 'inbox', shared: false }; + const shared = (name: string): FolderSel => ({ name, shared: true }); + + test('shared folder A -> shared folder B (same owner)', async ({ page }) => { const s = subj('mv-a2b'); - await seedInto(teamA, s); + await seedRead(teamA, s); + await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamA'), dest: shared('TeamB') }); - await login(page, carol); - await expandSharedFolders(page, alice.email); - const dest = await folderMailboxId(page, { name: 'TeamB', shared: true }); - await openFolder(page, { name: 'TeamA', shared: true }); - await forceSync(page); - - await moveEmailTo(page, s, dest); - await page.waitForTimeout(1500); - - expect(await ja.findEmailBySubject(s, teamB), 'message in TeamB').toBeTruthy(); + const inB = await ja.findEmailBySubject(s, teamB); + expect(inB, 'message in TeamB').toBeTruthy(); expect(await ja.findEmailBySubject(s, teamA), 'message left TeamA').toBeFalsy(); + expect(seenOf(inB), 'read state kept').toBe(true); }); - test('shared folder B -> shared folder A', async ({ page }) => { + test('shared folder B -> shared folder A (same owner)', async ({ page }) => { const s = subj('mv-b2a'); - await seedInto(teamB, s); + await seedRead(teamB, s); + await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamB'), dest: shared('TeamA') }); - await login(page, carol); - await expandSharedFolders(page, alice.email); - const dest = await folderMailboxId(page, { name: 'TeamA', shared: true }); - await openFolder(page, { name: 'TeamB', shared: true }); - await forceSync(page); - - await moveEmailTo(page, s, dest); - await page.waitForTimeout(1500); - - expect(await ja.findEmailBySubject(s, teamA), 'message in TeamA').toBeTruthy(); + const inA = await ja.findEmailBySubject(s, teamA); + expect(inA, 'message in TeamA').toBeTruthy(); expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy(); + expect(seenOf(inA), 'read state kept').toBe(true); }); - // 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 }) => { + // Cross-account cases: delivery + read state must hold (our fix); removing the + // source is pinned test.fail below (Stalwart #1150). + test('cross-owner shared -> shared: delivers and keeps read state', async ({ page }) => { + const s = subj('mv-a2c'); + await seedRead(teamA, s); + await uiMove(page, { subject: s, owners: [alice.email, bob.email], source: shared('TeamA'), dest: shared('TeamC') }); + + const inC = await jb.findEmailBySubject(s, teamC); + expect(inC, 'message in bob TeamC').toBeTruthy(); + expect(seenOf(inC), 'read state kept').toBe(true); + }); + + test('own account -> shared folder: delivers and keeps read state', 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); + const own = await jc.waitForEmail(s); + await jc.setSeen(own.id, true); + await uiMove(page, { subject: s, owners: [alice.email], source: inbox, dest: shared('TeamA') }); - await login(page, carol); - await expandSharedFolders(page, alice.email); - const dest = await folderMailboxId(page, { name: 'TeamA', shared: true }); - await openFolder(page, { role: 'inbox', shared: false }); - await forceSync(page); - - await moveEmailTo(page, s, dest); - await page.waitForTimeout(2000); - - // Expected (once supported): the message moves to the owner's shared TeamA. - expect(await ja.findEmailBySubject(s, teamA), 'message in shared TeamA').toBeTruthy(); + const inTeam = await ja.findEmailBySubject(s, teamA); + expect(inTeam, 'message in shared TeamA').toBeTruthy(); + expect(seenOf(inTeam), 'read state kept').toBe(true); }); - test.fail('shared folder -> own account', async ({ page }) => { + test('shared folder -> own account: delivers and keeps read state', async ({ page }) => { const s = subj('mv-sh2own'); - await seedInto(teamA, s); + await seedRead(teamA, s); + await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamA'), dest: inbox }); - await login(page, carol); - await expandSharedFolders(page, alice.email); - const dest = await folderMailboxId(page, { role: 'inbox', shared: false }); - await openFolder(page, { name: 'TeamA', shared: true }); - await forceSync(page); + const inOwn = await jc.findEmailBySubject(s); + expect(inOwn, 'message in own account').toBeTruthy(); + expect(seenOf(inOwn), 'read state kept').toBe(true); + }); - await moveEmailTo(page, s, dest); - await page.waitForTimeout(2000); + // Pinned failing: Stalwart's onSuccessDestroyOriginal leaves the original in + // place on a cross-account copy (support.stalw.art #1150). Un-pin once fixed + // upstream (our copyEmailAcrossAccounts already requests the destroy). + test.describe('source is removed after a cross-account move', () => { + test.fail(true, 'blocked by Stalwart #1150 (onSuccessDestroyOriginal destroys wrong id)'); - // Expected (once supported): the message arrives in carol's own Inbox. - expect(await jc.findEmailBySubject(s), 'message in own account').toBeTruthy(); + test('cross-owner shared -> shared', async ({ page }) => { + const s = subj('rm-a2c'); + await seedRead(teamA, s); + await uiMove(page, { subject: s, owners: [alice.email, bob.email], source: shared('TeamA'), dest: shared('TeamC') }); + expect(await ja.findEmailBySubject(s, teamA), 'original left alice TeamA').toBeFalsy(); + }); + + test('own account -> shared folder', async ({ page }) => { + const s = subj('rm-own2sh'); + await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' }); + const own = await jc.waitForEmail(s); + await jc.setSeen(own.id, true); + await uiMove(page, { subject: s, owners: [alice.email], source: inbox, dest: shared('TeamA') }); + expect(await jc.findEmailBySubject(s), 'original left own account').toBeFalsy(); + }); + + test('shared folder -> own account', async ({ page }) => { + const s = subj('rm-sh2own'); + await seedRead(teamA, s); + await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamA'), dest: inbox }); + expect(await ja.findEmailBySubject(s, teamA), 'original left shared TeamA').toBeFalsy(); + }); }); }); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 76aa0104..79b5fc67 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -1094,6 +1094,7 @@ export class DemoJMAPClient implements IJMAPClient { // ── S/MIME raw-email helpers ────────────────────────────────── async importRawEmail(): Promise { return generateDemoId('email'); } + async copyEmailAcrossAccounts(): Promise { return generateDemoId('email'); } async submitEmail(): Promise { /* no-op */ } async submitRawEmail(blob: Blob, identityId: string, diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index aef7cc44..35e6f1ad 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -345,4 +345,12 @@ export interface IJMAPClient { // ── S/MIME raw-email helpers ────────────────────────────────── importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record, accountId?: string): Promise; submitEmail(emailId: string, identityId: string): Promise; + /** + * 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; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d0071b6b..771eaed2 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -6394,6 +6394,43 @@ 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 { + // Email/copy drops keywords unless the create sets them, so carry the + // source's over — otherwise the moved message shows up as unread. + const srcResp = await this.request([ + ["Email/get", { accountId: fromAccountId, ids: [emailId], properties: ["keywords"] }, "0"], + ]); + const keywords = srcResp.methodResponses?.[0]?.[1]?.list?.[0]?.keywords ?? {}; + + // onSuccessDestroyOriginal is the spec-correct way to remove the source, but + // Stalwart currently destroys the copy's create-id instead of the source id, + // so the original is left behind — a duplicate on every cross-account move. + // Reported upstream (support.stalw.art #1150); this self-heals once fixed. + const response = await this.request([ + ["Email/copy", { + fromAccountId, + accountId: toAccountId, + create: { c: { id: emailId, mailboxIds: { [destMailboxId]: true }, keywords } }, + 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, diff --git a/stores/__tests__/email-store-cross-account-move.test.ts b/stores/__tests__/email-store-cross-account-move.test.ts new file mode 100644 index 00000000..fcfdb9c2 --- /dev/null +++ b/stores/__tests__/email-store-cross-account-move.test.ts @@ -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; + +function makeMailbox(overrides: Partial): 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; + let moveSpy: ReturnType; + 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 + ); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 14ca81d2..785a4966 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -161,6 +161,14 @@ interface EmailStore { deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise; moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; + /** + * 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; moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise; moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; /** @@ -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((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((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');