Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/(main)/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export default function Home() {
toggleStar,
setEmailKeywordsLocal,
moveToMailbox,
moveToMailboxCrossAware,
moveThreadToMailbox,
searchEmails,
searchQuery,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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}
Expand Down
163 changes: 105 additions & 58 deletions integration/tests/08-shared-moves.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof folderMailboxId>[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<void> {
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<void> {
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 s = subj('mv-a2b');
await seedInto(teamA, s);
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);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { name: 'TeamB', shared: true });
await openFolder(page, { name: 'TeamA', shared: true });
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);
}

await moveEmailTo(page, s, dest);
await page.waitForTimeout(1500);
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 seedRead(teamA, s);
await uiMove(page, { subject: s, owners: [alice.email], source: shared('TeamA'), dest: shared('TeamB') });

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);
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);
});

await moveEmailTo(page, s, dest);
await page.waitForTimeout(1500);
// 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') });

expect(await ja.findEmailBySubject(s, teamA), 'message in TeamA').toBeTruthy();
expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy();
const inC = await jb.findEmailBySubject(s, teamC);
expect(inC, 'message in bob TeamC').toBeTruthy();
expect(seenOf(inC), '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 }) => {
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);

await moveEmailTo(page, s, dest);
await page.waitForTimeout(2000);
const inOwn = await jc.findEmailBySubject(s);
expect(inOwn, 'message in own account').toBeTruthy();
expect(seenOf(inOwn), 'read state kept').toBe(true);
});

// Expected (once supported): the message arrives in carol's own Inbox.
expect(await jc.findEmailBySubject(s), 'message in own account').toBeTruthy();
// 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)');

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();
});
});
});
1 change: 1 addition & 0 deletions lib/demo/demo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions lib/jmap/client-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}
37 changes: 37 additions & 0 deletions lib/jmap/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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(
blob: Blob,
mailboxIds: Record<string, boolean>,
Expand Down
Loading