test(integration): All Mail, message actions, and shared-folder sync

Extend the integration suite (now 22 tests) to cover:

- All Mail view (04): single-account merge of Inbox + custom folders with
  Junk excluded, and cross-account aggregation across every logged-in account.
- Message actions from the list context menu (05): mark read/unread, delete
  (→ Trash), mark-as-spam (→ Junk) and not-spam round-trip, verified on both
  the UI counters/row state and the server mailbox the message ends up in.
- Shared/delegated folders (06): a delegated folder (+ Trash/Junk) shared
  alice→carol; the shared folder renders with its counter, and read/unread/
  delete/spam performed there land correctly (server-verified).

Hooks added: data-testid on context-menu delete/spam/read-unread items
(via a testId prop on ContextMenuItem), data-shared on folder rows, and
testId/data-expanded on sidebar section headers to drive the Shared section.

Observations surfaced by the suite (asserted server-side / with a reconcile):
- mark-as-spam doesn't optimistically decrement the *source* counter the way
  delete does; a visibility reconcile settles it.
- shared *destination* counters (shared Trash/Junk) don't refresh live —
  forceSync reconciles the active account only, not shared accounts.
This commit is contained in:
Stefan Hildebrandt
2026-07-11 21:15:20 +02:00
parent b8809c2e69
commit e05fbb2fe9
8 changed files with 529 additions and 15 deletions
+89 -2
View File
@@ -12,6 +12,20 @@ import { JMAP_URL } from './config';
const CORE = 'urn:ietf:params:jmap:core';
const MAIL = 'urn:ietf:params:jmap:mail';
const PRINCIPALS = 'urn:ietf:params:jmap:principals';
/** Rights granted on a shared mailbox (JMAP ACL). */
export const FULL_MAILBOX_RIGHTS = {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: false,
mayDelete: false,
maySubmit: false,
};
interface JmapMailbox {
id: string;
@@ -49,16 +63,68 @@ export class JmapClient {
return c;
}
async request(methodCalls: MethodCall[]): Promise<any> {
async request(methodCalls: MethodCall[], using: string[] = [CORE, MAIL]): Promise<any> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: { Authorization: this.authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ using: [CORE, MAIL], methodCalls }),
body: JSON.stringify({ using, methodCalls }),
});
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
return res.json();
}
/** Resolve another user's principal id (needed as the key in `shareWith`). */
async principalIdByEmail(email: string): Promise<string> {
const r = await this.request(
[
['Principal/query', { accountId: this.accountId, filter: { email } }, '0'],
['Principal/get', { accountId: this.accountId, '#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' } }, '1'],
],
[CORE, PRINCIPALS],
);
const list = r.methodResponses[1][1].list as Array<{ id: string; email?: string }>;
const match = list.find((p) => p.email === email) ?? list[0];
if (!match) throw new Error(`No principal found for ${email}`);
return match.id;
}
/**
* Create a folder in this account and share it with `granteeEmail`. Returns
* the new mailbox id. The grantee then sees this account as a shared account
* in their JMAP session.
*/
async createSharedFolder(name: string, granteeEmail: string): Promise<string> {
const principalId = await this.principalIdByEmail(granteeEmail);
const r = await this.request([
['Mailbox/set', {
accountId: this.accountId,
create: { shared: { name, shareWith: { [principalId]: FULL_MAILBOX_RIGHTS } } },
}, '0'],
]);
const created = r.methodResponses[0][1].created?.shared;
if (!created) throw new Error(`createSharedFolder failed: ${JSON.stringify(r.methodResponses[0][1])}`);
return created.id;
}
/** Grant `granteeEmail` access to an existing mailbox of this account. */
async shareMailbox(mailboxId: string, granteeEmail: string): Promise<void> {
const principalId = await this.principalIdByEmail(granteeEmail);
await this.request([
['Mailbox/set', {
accountId: this.accountId,
update: { [mailboxId]: { [`shareWith/${principalId}`]: FULL_MAILBOX_RIGHTS } },
}, '0'],
]);
}
/** Grant `granteeEmail` access to a system folder (by role) of this account. */
async shareMailboxByRole(role: string, granteeEmail: string): Promise<string> {
const mb = await this.mailboxByRole(role);
if (!mb) throw new Error(`No ${role} mailbox to share`);
await this.shareMailbox(mb.id, granteeEmail);
return mb.id;
}
async mailboxes(): Promise<JmapMailbox[]> {
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
return r.methodResponses[0][1].list as JmapMailbox[];
@@ -114,6 +180,27 @@ export class JmapClient {
}
}
/** Move an email so it lives solely in `toMailboxId`. */
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
await this.request([
['Email/set', { accountId: this.accountId, update: { [emailId]: { mailboxIds: { [toMailboxId]: true } } } }, '0'],
]);
}
/** Deliver-and-file: create/find a custom folder and drop a message id into it. */
async moveEmailToFolder(emailId: string, folderName: string): Promise<string> {
const id = await this.createMailbox(folderName);
await this.moveEmail(emailId, id);
return id;
}
/** Set or clear the $seen keyword on an email. */
async setSeen(emailId: string, seen: boolean): Promise<void> {
await this.request([
['Email/set', { accountId: this.accountId, update: { [emailId]: { [`keywords/$seen`]: seen ? true : null } } }, '0'],
]);
}
/** Look up an email id by subject within an optional mailbox. */
async findEmailBySubject(subject: string, mailboxId?: string): Promise<any | undefined> {
const filter: Record<string, unknown> = { subject };