Merge pull request #680 from hildebrandttk/fix/cross-account-move
Fix/cross account move
This commit is contained in:
@@ -282,6 +282,7 @@ export default function Home() {
|
|||||||
toggleStar,
|
toggleStar,
|
||||||
setEmailKeywordsLocal,
|
setEmailKeywordsLocal,
|
||||||
moveToMailbox,
|
moveToMailbox,
|
||||||
|
moveToMailboxCrossAware,
|
||||||
moveThreadToMailbox,
|
moveThreadToMailbox,
|
||||||
searchEmails,
|
searchEmails,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
@@ -3229,7 +3230,7 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
onMoveToMailbox={async (emailId, mailboxId) => {
|
onMoveToMailbox={async (emailId, mailboxId) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
await moveToMailbox(client, emailId, mailboxId);
|
await moveToMailboxCrossAware(client, emailId, mailboxId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMarkAsSpam={async (email) => {
|
onMarkAsSpam={async (email) => {
|
||||||
@@ -3499,7 +3500,7 @@ export default function Home() {
|
|||||||
selectedMailbox={selectedMailbox}
|
selectedMailbox={selectedMailbox}
|
||||||
onMoveToMailbox={async (mailboxId) => {
|
onMoveToMailbox={async (mailboxId) => {
|
||||||
if (client && selectedEmail) {
|
if (client && selectedEmail) {
|
||||||
await moveToMailbox(client, selectedEmail.id, mailboxId);
|
await moveToMailboxCrossAware(client, selectedEmail.id, mailboxId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={isMobile ? "flex-1" : undefined}
|
className={isMobile ? "flex-1" : undefined}
|
||||||
|
|||||||
@@ -13,107 +13,154 @@ import {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Moving mail across the own-account / shared-folder boundary, in both
|
* Moving mail across the own-account / shared-folder boundary, in both
|
||||||
* directions, and between two shared folders. The move is driven from the list
|
* directions, and between two shared folders (same owner and across owners).
|
||||||
* context menu's "Move to" submenu; the authoritative check is the server-side
|
* The move is driven from the list context menu's "Move to" submenu; the
|
||||||
* mailbox the message ends up in, with the reliably-updating (own-account)
|
* authoritative check is the server-side mailbox the message ends up in.
|
||||||
* counters checked in the UI too.
|
*
|
||||||
|
* 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()}`;
|
const subj = (l: string) => `IT ${l} ${Date.now()}`;
|
||||||
|
type FolderSel = Parameters<typeof folderMailboxId>[1];
|
||||||
|
|
||||||
test.describe('Shared-folder moves', () => {
|
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 jc: JmapClient; // grantee
|
||||||
let teamA: string;
|
let teamA: string;
|
||||||
let teamB: string;
|
let teamB: string;
|
||||||
|
let teamC: string; // owned by bob
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test.beforeEach(async () => {
|
||||||
ja = await JmapClient.connect(alice.email, alice.password);
|
ja = await JmapClient.connect(alice.email, alice.password);
|
||||||
|
jb = await JmapClient.connect(bob.email, bob.password);
|
||||||
jc = await JmapClient.connect(carol.email, carol.password);
|
jc = await JmapClient.connect(carol.email, carol.password);
|
||||||
await ja.reset();
|
await ja.reset();
|
||||||
|
await jb.reset();
|
||||||
await jc.reset();
|
await jc.reset();
|
||||||
teamA = await ja.createSharedFolder('TeamA', carol.email);
|
teamA = await ja.createSharedFolder('TeamA', carol.email);
|
||||||
teamB = await ja.createSharedFolder('TeamB', 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<void> {
|
// Seed a message into a mailbox and mark it read, so a lost $seen after the
|
||||||
const acct = owner === ja ? alice : carol;
|
// move is observable as the moved copy coming back unread.
|
||||||
|
async function seedRead(mailboxId: string, subject: string, owner = ja): Promise<void> {
|
||||||
|
const acct = owner === ja ? alice : owner === jb ? bob : carol;
|
||||||
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
|
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
|
||||||
const m = await owner.waitForEmail(subject);
|
const m = await owner.waitForEmail(subject);
|
||||||
await owner.moveEmail(m.id, mailboxId);
|
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<void> {
|
||||||
|
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');
|
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);
|
const inB = await ja.findEmailBySubject(s, teamB);
|
||||||
await expandSharedFolders(page, alice.email);
|
expect(inB, 'message in TeamB').toBeTruthy();
|
||||||
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();
|
|
||||||
expect(await ja.findEmailBySubject(s, teamA), 'message left TeamA').toBeFalsy();
|
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');
|
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);
|
const inA = await ja.findEmailBySubject(s, teamA);
|
||||||
await expandSharedFolders(page, alice.email);
|
expect(inA, 'message in TeamA').toBeTruthy();
|
||||||
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();
|
|
||||||
expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy();
|
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
|
// Cross-account cases: delivery + read state must hold (our fix); removing the
|
||||||
// shared folder as a destination for an own-account message, but clicking it
|
// source is pinned test.fail below (Stalwart #1150).
|
||||||
// does NOT relocate the message across the account boundary — it stays put.
|
test('cross-owner shared -> shared: delivers and keeps read state', async ({ page }) => {
|
||||||
// Same in reverse (shared -> own). If cross-account moves get implemented,
|
const s = subj('mv-a2c');
|
||||||
// these will start passing; flip them back to plain tests then.
|
await seedRead(teamA, s);
|
||||||
test.fail('own account -> shared folder', async ({ page }) => {
|
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');
|
const s = subj('mv-own2sh');
|
||||||
await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' });
|
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);
|
const inTeam = await ja.findEmailBySubject(s, teamA);
|
||||||
await expandSharedFolders(page, alice.email);
|
expect(inTeam, 'message in shared TeamA').toBeTruthy();
|
||||||
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
|
expect(seenOf(inTeam), 'read state kept').toBe(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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
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);
|
const inOwn = await jc.findEmailBySubject(s);
|
||||||
await expandSharedFolders(page, alice.email);
|
expect(inOwn, 'message in own account').toBeTruthy();
|
||||||
const dest = await folderMailboxId(page, { role: 'inbox', shared: false });
|
expect(seenOf(inOwn), 'read state kept').toBe(true);
|
||||||
await openFolder(page, { name: 'TeamA', shared: true });
|
});
|
||||||
await forceSync(page);
|
|
||||||
|
|
||||||
await moveEmailTo(page, s, dest);
|
// Pinned failing: Stalwart's onSuccessDestroyOriginal leaves the original in
|
||||||
await page.waitForTimeout(2000);
|
// 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.
|
test('cross-owner shared -> shared', async ({ page }) => {
|
||||||
expect(await jc.findEmailBySubject(s), 'message in own account').toBeTruthy();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1094,6 +1094,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||||
|
|
||||||
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
||||||
|
async copyEmailAcrossAccounts(): Promise<string> { return generateDemoId('email'); }
|
||||||
async submitEmail(): Promise<void> { /* no-op */ }
|
async submitEmail(): Promise<void> { /* no-op */ }
|
||||||
async submitRawEmail(blob: Blob,
|
async submitRawEmail(blob: Blob,
|
||||||
identityId: string,
|
identityId: string,
|
||||||
|
|||||||
@@ -345,4 +345,12 @@ export interface IJMAPClient {
|
|||||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): 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>;
|
||||||
|
/**
|
||||||
|
* 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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6394,6 +6394,43 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
* a shared mailbox owned by another user). When omitted, falls back to the
|
* a shared mailbox owned by another user). When omitted, falls back to the
|
||||||
* client's own primary account.
|
* client's own primary account.
|
||||||
*/
|
*/
|
||||||
|
async copyEmailAcrossAccounts(
|
||||||
|
emailId: string,
|
||||||
|
fromAccountId: string,
|
||||||
|
toAccountId: string,
|
||||||
|
destMailboxId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
// 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(
|
async importRawEmail(
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
mailboxIds: Record<string, boolean>,
|
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
@@ -161,6 +161,14 @@ interface EmailStore {
|
|||||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||||
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => 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>;
|
moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise<void>;
|
||||||
moveThreadToMailbox: (client: IJMAPClient, emailId: 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
|
* Builds the `UnifiedAccountClient[]` list used by every unified fan-out
|
||||||
* action (browse, load-more, search). Each entry has a JMAP client plus a
|
* 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) => {
|
moveToMailbox: async (client, emailId, destinationMailboxId) => {
|
||||||
try {
|
try {
|
||||||
const email = get().emails.find(e => e.id === emailId);
|
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.
|
// the source clean in the happy path.
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
emailIds.map(async (emailId) => {
|
emailIds.map(async (emailId) => {
|
||||||
// When the source is a delegated/shared mailbox, the email,
|
// Delegated/shared folders: one client reaches both accounts, so a
|
||||||
// its blob, and the destroy all live in the owner's JMAP
|
// server-side Email/copy moves the message. A client can't stage a
|
||||||
// account, not the source client's primary one.
|
// 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);
|
const full = await sourceClient.getEmail(emailId, sourceJmapAccountId);
|
||||||
if (!full?.blobId) {
|
if (!full?.blobId) {
|
||||||
throw new Error('Source email has no raw blob to copy');
|
throw new Error('Source email has no raw blob to copy');
|
||||||
|
|||||||
Reference in New Issue
Block a user