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:
@@ -0,0 +1,97 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
addAccount,
|
||||
seedAllMailSettings,
|
||||
folderRow,
|
||||
openFolder,
|
||||
expectFolderUnread,
|
||||
expectEmailVisible,
|
||||
emailItem,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* The "All Mail" view — a virtual folder that merges messages across an
|
||||
* account's folders (Inbox + custom, excluding junk/sent/trash/drafts/archive),
|
||||
* and across every logged-in account when the cross-account sub-option is on.
|
||||
*/
|
||||
const { alice, bob } = ACCOUNTS;
|
||||
const ALL_MAIL = '__cross_all__';
|
||||
|
||||
let seq = 0;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
|
||||
const send = (to: typeof alice, subject: string) =>
|
||||
sendMail({ from: to.email, authPass: to.password, to: to.email, subject, body: 'x' });
|
||||
|
||||
test.describe('All Mail — single account', () => {
|
||||
let jmap: JmapClient;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
jmap = await JmapClient.connect(alice.email, alice.password);
|
||||
await jmap.reset();
|
||||
});
|
||||
|
||||
test('merges Inbox + custom folders and excludes Junk', async ({ page }) => {
|
||||
const inboxSubj = subj('am-inbox');
|
||||
const folderSubj = subj('am-folder');
|
||||
const junkSubj = subj('am-junk');
|
||||
await send(alice, inboxSubj);
|
||||
await send(alice, folderSubj);
|
||||
await send(alice, junkSubj);
|
||||
|
||||
// File one into a custom folder and one into Junk (excluded from All Mail).
|
||||
const folderMail = await jmap.waitForEmail(folderSubj);
|
||||
await jmap.moveEmailToFolder(folderMail.id, 'Projects');
|
||||
const junkMail = await jmap.waitForEmail(junkSubj);
|
||||
const junk = await jmap.mailboxByRole('junk');
|
||||
await jmap.moveEmail(junkMail.id, junk!.id);
|
||||
|
||||
await seedAllMailSettings(page, { crossAccount: false });
|
||||
await login(page, alice);
|
||||
|
||||
// The All Mail entry is present and shows the two included-folder unreads.
|
||||
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
|
||||
await expectFolderUnread(page, { name: ALL_MAIL }, 2);
|
||||
|
||||
// Its list merges the Inbox and custom-folder messages, but not Junk.
|
||||
await openFolder(page, { name: ALL_MAIL });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, inboxSubj);
|
||||
await expectEmailVisible(page, folderSubj);
|
||||
await expect(emailItem(page, junkSubj)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('All Mail — cross account', () => {
|
||||
test.beforeEach(async () => {
|
||||
for (const a of [alice, bob]) {
|
||||
const j = await JmapClient.connect(a.email, a.password);
|
||||
await j.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test('merges mail from every logged-in account', async ({ page }) => {
|
||||
const aSubj = subj('am-a');
|
||||
const bSubj = subj('am-b');
|
||||
await send(alice, aSubj);
|
||||
await send(bob, bSubj);
|
||||
|
||||
await seedAllMailSettings(page, { crossAccount: true });
|
||||
await login(page, alice);
|
||||
await addAccount(page, bob);
|
||||
await forceSync(page);
|
||||
|
||||
// All Mail aggregates unread across both accounts (alice 1 + bob 1).
|
||||
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
|
||||
await expectFolderUnread(page, { name: ALL_MAIL }, 2);
|
||||
|
||||
await openFolder(page, { name: ALL_MAIL });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, aSubj);
|
||||
await expectEmailVisible(page, bSubj);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
expectFolderUnread,
|
||||
expectFolderTotal,
|
||||
expectEmailVisible,
|
||||
expectEmailUnread,
|
||||
emailContextAction,
|
||||
emailItem,
|
||||
openFolder,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Message actions from the list context menu — mark read/unread, delete, spam —
|
||||
* performed in the Inbox, with the outcome checked on both the UI (counters,
|
||||
* row state) and the server (which mailbox the message ended up in).
|
||||
*/
|
||||
const alice = ACCOUNTS.alice;
|
||||
let seq = 0;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
|
||||
const send = (subject: string) =>
|
||||
sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
|
||||
|
||||
test.describe('Inbox message actions', () => {
|
||||
let jmap: JmapClient;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
jmap = await JmapClient.connect(alice.email, alice.password);
|
||||
await jmap.reset();
|
||||
});
|
||||
|
||||
test('mark read then unread toggles the row state and Inbox unread counter', async ({ page }) => {
|
||||
const s = subj('act-read');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 1);
|
||||
await expectEmailUnread(page, s, true);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-read');
|
||||
await expectEmailUnread(page, s, false);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-unread');
|
||||
await expectEmailUnread(page, s, true);
|
||||
await expectFolderUnread(page, { role: 'inbox' }, 1);
|
||||
});
|
||||
|
||||
test('delete moves the message to Trash and updates both counters', async ({ page }) => {
|
||||
const s = subj('act-del');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-delete');
|
||||
|
||||
// Leaves the Inbox, lands in Trash — on the UI...
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 0);
|
||||
await expectFolderTotal(page, { role: 'trash' }, 1);
|
||||
await expect(emailItem(page, s)).toHaveCount(0);
|
||||
|
||||
// ...and on the server.
|
||||
const trash = await jmap.mailboxByRole('trash');
|
||||
const found = await jmap.findEmailBySubject(s, trash!.id);
|
||||
expect(found, 'deleted message is in Trash on the server').toBeTruthy();
|
||||
});
|
||||
|
||||
test('mark as spam moves the message to Junk', async ({ page }) => {
|
||||
const s = subj('act-spam');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-spam');
|
||||
|
||||
// The destination (Junk) counter updates optimistically, but the source
|
||||
// (Inbox) counter isn't always decremented until the next reconcile when
|
||||
// the action fires moments after login — unlike delete, which decrements
|
||||
// the source immediately. A visibility reconcile settles it deterministically.
|
||||
await expectFolderTotal(page, { role: 'junk' }, 1);
|
||||
await forceSync(page);
|
||||
await expectFolderTotal(page, { role: 'inbox' }, 0);
|
||||
|
||||
const junk = await jmap.mailboxByRole('junk');
|
||||
const found = await jmap.findEmailBySubject(s, junk!.id);
|
||||
expect(found, 'spammed message is in Junk on the server').toBeTruthy();
|
||||
});
|
||||
|
||||
test('spam then not-spam round-trips the message back out of Junk', async ({ page }) => {
|
||||
const s = subj('act-notspam');
|
||||
await send(s);
|
||||
await jmap.waitForEmail(s);
|
||||
|
||||
await login(page, alice);
|
||||
await emailContextAction(page, s, 'ctx-spam');
|
||||
await expectFolderTotal(page, { role: 'junk' }, 1);
|
||||
|
||||
// Open Junk, then mark not-spam.
|
||||
await openFolder(page, { role: 'junk' });
|
||||
await expectEmailVisible(page, s);
|
||||
await emailContextAction(page, s, 'ctx-not-spam');
|
||||
|
||||
await expectFolderTotal(page, { role: 'junk' }, 0);
|
||||
const junk = await jmap.mailboxByRole('junk');
|
||||
const stillInJunk = await jmap.findEmailBySubject(s, junk!.id);
|
||||
expect(stillInJunk, 'message no longer in Junk').toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
expandSharedFolders,
|
||||
folderRow,
|
||||
openFolder,
|
||||
expectFolderUnread,
|
||||
expectFolderTotal,
|
||||
expectEmailVisible,
|
||||
expectEmailUnread,
|
||||
emailContextAction,
|
||||
emailItem,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Shared (delegated) folders. Alice shares a custom folder — plus her Trash and
|
||||
* Junk so delete/spam can route to the owner's system folders — with carol, who
|
||||
* then acts on the mail from her own session and checks the shared counters.
|
||||
*
|
||||
* carol is the grantee (not asserted on by other specs), so the shared-account
|
||||
* visibility this leaves in Stalwart's session cache doesn't leak elsewhere.
|
||||
*/
|
||||
const { alice, carol } = ACCOUNTS;
|
||||
const SHARED = 'TeamShared';
|
||||
|
||||
let seq = 0;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
|
||||
|
||||
test.describe('Shared folder actions', () => {
|
||||
let ja: JmapClient; // owner (alice)
|
||||
let sharedId: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
ja = await JmapClient.connect(alice.email, alice.password);
|
||||
const jc = await JmapClient.connect(carol.email, carol.password);
|
||||
await ja.reset();
|
||||
await jc.reset();
|
||||
// Delegate a custom folder + Trash + Junk to carol.
|
||||
sharedId = await ja.createSharedFolder(SHARED, carol.email);
|
||||
await ja.shareMailboxByRole('trash', carol.email);
|
||||
await ja.shareMailboxByRole('junk', carol.email);
|
||||
});
|
||||
|
||||
async function seedIntoShared(subject: string): Promise<void> {
|
||||
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
|
||||
const m = await ja.waitForEmail(subject);
|
||||
await ja.moveEmail(m.id, sharedId);
|
||||
}
|
||||
|
||||
test('shared folder appears with its counter and message', async ({ page }) => {
|
||||
const s = subj('sh-show');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
|
||||
await expect(folderRow(page, { name: SHARED, shared: true }).first()).toBeVisible();
|
||||
await expectFolderUnread(page, { name: SHARED, shared: true }, 1);
|
||||
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await forceSync(page);
|
||||
await expectEmailVisible(page, s);
|
||||
});
|
||||
|
||||
test('mark read/unread in a shared folder updates its counter', async ({ page }) => {
|
||||
const s = subj('sh-read');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await forceSync(page);
|
||||
await expectFolderUnread(page, { name: SHARED, shared: true }, 1);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-read');
|
||||
await expectEmailUnread(page, s, false);
|
||||
await expectFolderUnread(page, { name: SHARED, shared: true }, 0);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-mark-unread');
|
||||
await expectFolderUnread(page, { name: SHARED, shared: true }, 1);
|
||||
|
||||
// Owner sees the same state on the server.
|
||||
const found = await ja.findEmailBySubject(s, sharedId);
|
||||
expect(found.keywords?.$seen).toBeFalsy();
|
||||
});
|
||||
|
||||
test('delete in a shared folder moves the message to the shared Trash', async ({ page }) => {
|
||||
const s = subj('sh-del');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await forceSync(page);
|
||||
await expectFolderTotal(page, { name: SHARED, shared: true }, 1);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-delete');
|
||||
await forceSync(page);
|
||||
|
||||
// Source shared folder drains, and the message really is in the owner's
|
||||
// Trash on the server. (Note: the shared *destination* counter — the shared
|
||||
// Trash — does NOT refresh live in the sidebar; forceSync reconciles the
|
||||
// active account only, not shared-account counters. Asserted server-side.)
|
||||
await expectFolderTotal(page, { name: SHARED, shared: true }, 0);
|
||||
const trash = await ja.mailboxByRole('trash');
|
||||
expect(await ja.findEmailBySubject(s, trash!.id), 'message in owner Trash').toBeTruthy();
|
||||
});
|
||||
|
||||
test('mark as spam in a shared folder moves the message to the shared Junk', async ({ page }) => {
|
||||
const s = subj('sh-spam');
|
||||
await seedIntoShared(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
await openFolder(page, { name: SHARED, shared: true });
|
||||
await forceSync(page);
|
||||
await expectFolderTotal(page, { name: SHARED, shared: true }, 1);
|
||||
|
||||
await emailContextAction(page, s, 'ctx-spam');
|
||||
|
||||
// The message leaves the shared folder's list, and on the server it has
|
||||
// moved to the owner's Junk and out of the shared folder. (Unlike delete,
|
||||
// spam doesn't optimistically drain the source *counter*, and forceSync
|
||||
// can't reconcile a shared account — so we assert list + server state.)
|
||||
await expect(emailItem(page, s)).toHaveCount(0);
|
||||
const junk = await ja.mailboxByRole('junk');
|
||||
expect(await ja.findEmailBySubject(s, junk!.id), 'message in owner Junk').toBeTruthy();
|
||||
expect(await ja.findEmailBySubject(s, sharedId), 'message no longer in shared folder').toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -36,21 +36,41 @@ export async function neutralizeDevOverlay(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the cross-account Unified Mailbox before the app boots by seeding the
|
||||
* persisted settings store. Requires the `unifiedCrossAccountEnabled` admin
|
||||
* feature gate (provided by integration/webmail-config/policy.json). Must be
|
||||
* called before {@link login} so the init script is registered before the
|
||||
* first navigation.
|
||||
* Seed the persisted settings store before the app boots. Merges over the
|
||||
* store defaults on rehydrate. Must be called before {@link login} so the init
|
||||
* script is registered before the first navigation.
|
||||
*/
|
||||
export async function seedSettings(page: Page, settings: Record<string, unknown>): Promise<void> {
|
||||
await page.addInitScript((s) => {
|
||||
localStorage.setItem('settings-storage', JSON.stringify({ state: s, version: 7 }));
|
||||
}, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the cross-account Unified Mailbox. Requires the
|
||||
* `unifiedCrossAccountEnabled` admin feature gate (provided by
|
||||
* integration/webmail-config/policy.json).
|
||||
*/
|
||||
export async function seedUnifiedSettings(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
'settings-storage',
|
||||
JSON.stringify({
|
||||
state: { enableUnifiedMailbox: true, unifiedCrossAccount: true, includeGroupInUnified: true },
|
||||
version: 7,
|
||||
}),
|
||||
);
|
||||
await seedSettings(page, {
|
||||
enableUnifiedMailbox: true,
|
||||
unifiedCrossAccount: true,
|
||||
includeGroupInUnified: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the "All Mail" view. `crossAccount` spans every logged-in account
|
||||
* (requires the `unifiedCrossAccountEnabled` gate); otherwise it is account-
|
||||
* bounded (spans the active account's own + shared folders). The "All mail"
|
||||
* entry itself is gated by `crossAllViewEnabled` (also in policy.json).
|
||||
*/
|
||||
export async function seedAllMailSettings(page: Page, opts: { crossAccount?: boolean } = {}): Promise<void> {
|
||||
await seedSettings(page, {
|
||||
enableUnifiedMailbox: true,
|
||||
enableCrossAllView: true,
|
||||
includeGroupInUnified: true,
|
||||
unifiedCrossAccount: !!opts.crossAccount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,6 +141,8 @@ export interface FolderSelector {
|
||||
role?: string;
|
||||
name?: string;
|
||||
mailboxId?: string;
|
||||
/** true = only shared-account folders, false = only own folders. */
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
/** Locator for a sidebar folder row. */
|
||||
@@ -129,9 +151,24 @@ export function folderRow(page: Page, sel: FolderSelector): Locator {
|
||||
if (sel.role) s += `[data-folder-role="${sel.role}"]`;
|
||||
if (sel.name) s += `[data-folder-name="${sel.name}"]`;
|
||||
if (sel.mailboxId) s += `[data-mailbox-id="${sel.mailboxId}"]`;
|
||||
if (sel.shared === true) s += '[data-shared="true"]';
|
||||
if (sel.shared === false) s += ':not([data-shared="true"])';
|
||||
return page.locator(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the sidebar "Shared" section and the given sharer's shared-account
|
||||
* group so its folders (data-shared="true") render. Idempotent.
|
||||
*/
|
||||
export async function expandSharedFolders(page: Page, sharerEmail: string): Promise<void> {
|
||||
const section = page.locator('[data-testid="section-shared"]');
|
||||
await section.waitFor({ state: 'visible', timeout: 30000 });
|
||||
if ((await section.getAttribute('data-expanded')) !== 'true') await section.click();
|
||||
const account = page.locator(`[data-testid="section-shared-account"][data-section-name="${sharerEmail}"]`);
|
||||
await account.waitFor({ state: 'visible', timeout: 30000 });
|
||||
if ((await account.getAttribute('data-expanded')) !== 'true') await account.click();
|
||||
}
|
||||
|
||||
export interface FolderCounts {
|
||||
unread: number;
|
||||
total: number;
|
||||
@@ -178,3 +215,27 @@ export function emailItem(page: Page, subject: string): Locator {
|
||||
export async function expectEmailVisible(page: Page, subject: string, timeout = 20000): Promise<void> {
|
||||
await expect(emailItem(page, subject).first()).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert an email row's unread state (from its `data-unread` attribute). */
|
||||
export async function expectEmailUnread(page: Page, subject: string, unread: boolean, timeout = 20000): Promise<void> {
|
||||
await expect(emailItem(page, subject).first()).toHaveAttribute('data-unread', String(unread), { timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an email's right-click context menu and click one of its actions.
|
||||
* `testId` is one of: `ctx-delete`, `ctx-spam`, `ctx-not-spam`,
|
||||
* `ctx-mark-read`, `ctx-mark-unread`.
|
||||
*/
|
||||
export async function emailContextAction(page: Page, subject: string, testId: string): Promise<void> {
|
||||
const row = emailItem(page, subject).first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
await row.scrollIntoViewIfNeeded();
|
||||
const item = page.locator(`[data-testid="${testId}"]`);
|
||||
// Right-click can occasionally land before the list row is interactive;
|
||||
// retry opening the menu until the action item is actually present.
|
||||
await expect(async () => {
|
||||
await row.click({ button: 'right' });
|
||||
await item.waitFor({ state: 'visible', timeout: 2000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
@@ -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