Files
SRCmail/integration/tests/helpers/jmap.ts
T
Stefan Hildebrandt b8809c2e69 test(integration): dockerized webmail⇆Stalwart Playwright sync suite
Add an end-to-end integration harness that runs the webmail against a real
Stalwart mail server in Docker and drives it with Playwright, focused on the
mail/folder synchronisation behaviour (unread/total counters, folder-list
sync, account-scoped Unified Mailbox) that the unified-mailbox work touches.

- integration/ stack: Stalwart (declarative bootstrap: alice/bob/carol,
  submission + IMAP listeners, permissive CORS) + webmail (dev mode, so the
  browser's plaintext cross-origin JMAP calls aren't blocked by the prod CSP).
- Helpers: dependency-free SMTP submit client, JMAP client for seeding /
  inspecting server state, and page helpers (login, add/switch account,
  locale-independent folder-counter reads).
- Specs: login, single-account sync (receive/read/move/delete/folder-create/
  burst) and multi-account (per-account isolation + cross-account Unified
  Inbox aggregation + background-account delivery). 12 tests, all green.
- Add focused data-testid hooks to the mail UI (folder rows + counters, email
  list items, account switcher, composer) for stable selectors.
- Exclude examples/ and integration/ from tsconfig/eslint/.dockerignore.
2026-07-11 21:15:13 +02:00

143 lines
5.5 KiB
TypeScript

/**
* Minimal JMAP client for test setup/inspection against Stalwart.
*
* Uses global fetch (Node 18+). Not a full JMAP implementation — just the
* pieces the integration tests need: authenticate, read/reset mailboxes,
* create folders, and poll for delivery. Assertions on *server* state (via
* this client) are kept separate from assertions on *UI* state (via the page),
* so a failing test can tell whether the bug is in delivery or in the webmail's
* sync.
*/
import { JMAP_URL } from './config';
const CORE = 'urn:ietf:params:jmap:core';
const MAIL = 'urn:ietf:params:jmap:mail';
interface JmapMailbox {
id: string;
name: string;
role: string | null;
parentId: string | null;
totalEmails: number;
unreadEmails: number;
}
type MethodCall = [string, Record<string, unknown>, string];
export class JmapClient {
private authHeader: string;
private apiUrl: string;
accountId = '';
private constructor(private email: string, password: string) {
this.authHeader = 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
// Stalwart advertises apiUrl on its configured hostname (mail.example.org);
// rewrite onto the reachable origin, exactly as the app client does.
this.apiUrl = `${JMAP_URL}/jmap/`;
}
static async connect(email: string, password: string): Promise<JmapClient> {
const c = new JmapClient(email, password);
const res = await fetch(`${JMAP_URL}/jmap/session`, {
headers: { Authorization: c.authHeader },
});
if (!res.ok) throw new Error(`JMAP session failed for ${email}: ${res.status}`);
const session = await res.json();
const primary = session.primaryAccounts?.[MAIL];
if (!primary) throw new Error(`No mail account for ${email} in JMAP session`);
c.accountId = primary;
return c;
}
async request(methodCalls: MethodCall[]): 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 }),
});
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
return res.json();
}
async mailboxes(): Promise<JmapMailbox[]> {
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
return r.methodResponses[0][1].list as JmapMailbox[];
}
async mailboxByRole(role: string): Promise<JmapMailbox | undefined> {
return (await this.mailboxes()).find((m) => m.role === role);
}
async mailboxByName(name: string): Promise<JmapMailbox | undefined> {
return (await this.mailboxes()).find((m) => m.name === name);
}
/** Create a folder (top-level) and return its id. Idempotent by name. */
async createMailbox(name: string, parentId: string | null = null): Promise<string> {
const existing = await this.mailboxByName(name);
if (existing) return existing.id;
const r = await this.request([
['Mailbox/set', { accountId: this.accountId, create: { new: { name, parentId } } }, '0'],
]);
const created = r.methodResponses[0][1].created?.new;
if (!created) throw new Error(`Mailbox/set create failed: ${JSON.stringify(r.methodResponses[0][1])}`);
return created.id;
}
async deleteMailboxByName(name: string): Promise<void> {
const mb = await this.mailboxByName(name);
if (!mb) return;
await this.request([
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: [mb.id] }, '0'],
]);
}
private async allEmailIds(): Promise<string[]> {
const r = await this.request([['Email/query', { accountId: this.accountId, limit: 5000 }, '0']]);
return r.methodResponses[0][1].ids as string[];
}
/**
* Reset a mailbox to a clean slate: destroy every message and delete any
* non-system (custom) folder. System folders (Inbox/Sent/Trash/…) are kept.
*/
async reset(): Promise<void> {
const ids = await this.allEmailIds();
if (ids.length) {
await this.request([['Email/set', { accountId: this.accountId, destroy: ids }, '0']]);
}
const custom = (await this.mailboxes()).filter((m) => !m.role);
if (custom.length) {
await this.request([
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: custom.map((m) => m.id) }, '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 };
if (mailboxId) filter.inMailbox = mailboxId;
const r = await this.request([
['Email/query', { accountId: this.accountId, filter }, '0'],
['Email/get', {
accountId: this.accountId,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'preview'],
}, '1'],
]);
return r.methodResponses[1][1].list[0];
}
/** Poll until a message with `subject` is delivered (or throw on timeout). */
async waitForEmail(subject: string, opts: { mailboxId?: string; timeoutMs?: number } = {}): Promise<any> {
const deadline = Date.now() + (opts.timeoutMs ?? 15000);
for (;;) {
const found = await this.findEmailBySubject(subject, opts.mailboxId);
if (found) return found;
if (Date.now() > deadline) throw new Error(`Timed out waiting for email "${subject}" (${this.email})`);
await new Promise((r) => setTimeout(r, 500));
}
}
}