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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user