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
+3
View File
@@ -295,6 +295,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={Trash2}
label={t("delete")}
testId="ctx-delete"
onClick={() =>
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
}
@@ -410,6 +411,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
onClick={() =>
handleAction(
showBatchActions
@@ -429,6 +431,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
onClick={() =>
handleAction(() =>
showBatchActions
+11
View File
@@ -259,6 +259,7 @@ interface SidebarRowProps {
testRole?: string | null;
testName?: string;
testMailboxId?: string;
testShared?: boolean;
}
function SidebarRow({
@@ -282,6 +283,7 @@ function SidebarRow({
testRole,
testName,
testMailboxId,
testShared,
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -294,6 +296,7 @@ function SidebarRow({
data-folder-role={testRole ?? undefined}
data-folder-name={testName ?? undefined}
data-mailbox-id={testMailboxId ?? undefined}
data-shared={testShared ? 'true' : undefined}
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
@@ -373,6 +376,7 @@ function SidebarSectionHeader({
first,
icon,
sub,
testId,
}: {
label: string;
expanded: boolean;
@@ -383,6 +387,7 @@ function SidebarSectionHeader({
first?: boolean;
icon?: ReactNode;
sub?: boolean;
testId?: string;
}) {
if (isCollapsed) {
return first ? null : <div className="h-px bg-border/50 mx-2 my-2" aria-hidden />;
@@ -397,6 +402,9 @@ function SidebarSectionHeader({
return (
<button
onClick={onToggle}
data-testid={testId}
data-section-name={label}
data-expanded={expanded ? 'true' : 'false'}
className={cn(
"group w-full flex items-center pb-1 select-none rounded-sm hover:bg-muted/40 transition-colors",
paddingX,
@@ -497,6 +505,7 @@ function MailboxTreeItem({
testRole={node.role}
testName={node.name}
testMailboxId={node.id}
testShared={node.isShared}
depth={node.depth}
isSelected={isSelected}
isVirtual={isVirtualNode}
@@ -1212,6 +1221,7 @@ export function Sidebar({
expanded={sharedExpanded}
onToggle={toggleShared}
isCollapsed={isCollapsed}
testId="section-shared"
/>
{((sharedExpanded && !isCollapsed) || isCollapsed) && (
<>
@@ -1226,6 +1236,7 @@ export function Sidebar({
isCollapsed={isCollapsed}
sub
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
testId="section-shared-account"
/>
{accountExpanded && !isCollapsed && account.children.map((child) => (
<MailboxTreeItem
+4
View File
@@ -108,6 +108,8 @@ interface ContextMenuItemProps {
disabled?: boolean;
destructive?: boolean;
shortcut?: string;
/** Stable hook for integration tests (not user-visible). */
testId?: string;
}
export function ContextMenuItem({
@@ -117,10 +119,12 @@ export function ContextMenuItem({
disabled = false,
destructive = false,
shortcut,
testId,
}: ContextMenuItemProps) {
return (
<button
role="menuitem"
data-testid={testId}
disabled={disabled}
className={cn(
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2",
+97
View File
@@ -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);
});
});
+117
View File
@@ -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();
});
});
+134
View File
@@ -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();
});
});
+74 -13
View File
@@ -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();
}
+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 };