test(integration): draft handling and shared-folder moves
Add draft and shared-folder-move coverage (suite now 31 tests). Findings are asserted server-side or pinned with test.fail where the UI is incomplete. Drafts (07): - multiple recipients (committed and typed-but-uncommitted) persist, and the draft reopens via the continue-draft button; - a server-created draft (with $draft) shows the continue-draft button; - a changed sender identity is saved to the draft on the server; - KNOWN BUG (test.fail): reopening a draft resets the From selector to the default identity instead of the one the draft was saved with. Shared-folder moves (08): - shared -> shared (same owner) moves work in both directions (server-verified); - KNOWN LIMITATION (test.fail): cross-account moves (own account <-> shared folder) don't relocate the message — the Move-to submenu offers the target but clicking it is a no-op. Hooks added: composer From select + save-status, viewer edit-draft button, context-menu "Move to" submenu + per-target testids (testId on ContextMenuSubMenu). Helpers: JMAP identities/createDraft/sharing, composer drive + move-via-submenu. README documents the findings.
This commit is contained in:
@@ -2071,7 +2071,7 @@ export function EmailComposer({
|
||||
<Button variant="ghost" size="icon" onClick={handleClose} className="h-9 w-9 md:h-8 md:w-8">
|
||||
<X className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2" data-testid="composer-save-status" data-status={saveStatus}>
|
||||
<h3 className="font-semibold text-base">{t('new_message')}</h3>
|
||||
{saveStatus === 'saving' && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -2136,6 +2136,7 @@ export function EmailComposer({
|
||||
<select
|
||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||
data-testid="composer-from"
|
||||
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
||||
>
|
||||
{identityGroups.length > 0
|
||||
|
||||
@@ -307,7 +307,7 @@ export function EmailContextMenu({
|
||||
|
||||
{/* Move to submenu */}
|
||||
{moveTree.length > 0 && (
|
||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
|
||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
|
||||
{(() => {
|
||||
const renderNodes = (nodes: MailboxNode[]) => {
|
||||
return nodes.map((node) => {
|
||||
@@ -320,6 +320,7 @@ export function EmailContextMenu({
|
||||
<ContextMenuItem
|
||||
icon={Icon}
|
||||
label={nodeLabel}
|
||||
testId={`move-to:${node.id}`}
|
||||
onClick={() =>
|
||||
handleAction(() =>
|
||||
showBatchActions
|
||||
|
||||
@@ -2781,6 +2781,7 @@ export function EmailViewer({
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onEditDraft()}
|
||||
data-testid="edit-draft"
|
||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('tooltips.edit_draft')}
|
||||
>
|
||||
|
||||
@@ -157,12 +157,15 @@ interface ContextMenuSubMenuProps {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
/** Stable hook for integration tests (not user-visible). */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuSubMenu({
|
||||
icon: Icon,
|
||||
label,
|
||||
children,
|
||||
testId,
|
||||
}: ContextMenuSubMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [subMenuPos, setSubMenuPos] = useState<Position | null>(null);
|
||||
@@ -236,6 +239,7 @@ export function ContextMenuSubMenu({
|
||||
role="menuitem"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen}
|
||||
data-testid={testId}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
<span className="flex-1">{label}</span>
|
||||
|
||||
+25
-1
@@ -80,9 +80,33 @@ integration/
|
||||
│ └── app.ts # login, add/switch account, folder-counter reads
|
||||
├── 01-login.spec.ts
|
||||
├── 02-mail-sync.spec.ts # single-account: receive/read/move/delete/folder-create
|
||||
└── 03-multi-account.spec.ts # isolation + cross-account Unified Inbox aggregation
|
||||
├── 03-multi-account.spec.ts # isolation + cross-account Unified Inbox aggregation
|
||||
├── 04-all-mail.spec.ts # All Mail view: single-account merge + cross-account
|
||||
├── 05-actions.spec.ts # context-menu read/unread, delete, spam (inbox)
|
||||
├── 06-shared-folders.spec.ts # delegated folder: appears + read/unread/delete/spam
|
||||
├── 07-drafts.spec.ts # multiple recipients, changed sender, continue-draft button
|
||||
└── 08-shared-moves.spec.ts # moving mail across own/shared and shared/shared
|
||||
```
|
||||
|
||||
## Findings surfaced by the suite
|
||||
|
||||
Some tests assert server-side truth (or use `test.fail` to pin a known gap)
|
||||
because the UI behaviour is currently incomplete. Worth a look:
|
||||
|
||||
- **Shared destination counters don't refresh live.** `forceSync`
|
||||
(visibilitychange) reconciles only the *active* account, so moving a message
|
||||
into a shared Trash/Junk/folder doesn't update that shared folder's sidebar
|
||||
badge until a full reload. Source counters and server state are correct.
|
||||
- **`mark-as-spam` doesn't optimistically decrement the source counter** the
|
||||
way `delete` does; it settles after a reconcile.
|
||||
- **Reopening a draft resets the From selector** to the default identity even
|
||||
though the draft was saved with (and the server retains) the chosen sender.
|
||||
Pinned with `test.fail` in `07-drafts`.
|
||||
- **Cross-account moves (own ⇆ shared folder) don't relocate the message.** The
|
||||
"Move to" submenu offers the shared folder, but clicking it is a no-op.
|
||||
Shared ⇆ shared (same owner) moves work. Pinned with `test.fail` in
|
||||
`08-shared-moves`.
|
||||
|
||||
## How the tests work
|
||||
|
||||
- **Mutations** are made out-of-band — mail is injected over SMTP
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
openComposer,
|
||||
addRecipient,
|
||||
setFrom,
|
||||
setSubject,
|
||||
waitDraftSaved,
|
||||
closeComposer,
|
||||
composerRecipients,
|
||||
openFolder,
|
||||
emailItem,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Draft handling. Focus areas reported as flaky by the user:
|
||||
* - the "continue draft" (edit-draft) button in the message view,
|
||||
* - multiple recipients being persisted to the draft,
|
||||
* - a changed sender identity being persisted to the draft.
|
||||
*
|
||||
* Each test drives the composer, lets it auto-save, then verifies the draft on
|
||||
* the server (JMAP) and by reopening it in the UI.
|
||||
*/
|
||||
const { alice, bob, carol } = ACCOUNTS;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}`;
|
||||
|
||||
async function draftBody(page: import('@playwright/test').Page, text: string) {
|
||||
await page.locator('.ProseMirror').first().fill(text);
|
||||
}
|
||||
|
||||
test.describe('Drafts', () => {
|
||||
let jmap: JmapClient;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
jmap = await JmapClient.connect(alice.email, alice.password);
|
||||
await jmap.reset();
|
||||
});
|
||||
|
||||
test('multiple recipients save and reopen via the continue-draft button', async ({ page }) => {
|
||||
const subject = subj('draft-multi');
|
||||
await login(page, alice);
|
||||
|
||||
await openComposer(page);
|
||||
await addRecipient(page, bob.email);
|
||||
await addRecipient(page, carol.email);
|
||||
await setSubject(page, subject);
|
||||
await draftBody(page, 'draft body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
// Server: the draft carries BOTH recipients.
|
||||
const drafts = await jmap.mailboxByRole('drafts');
|
||||
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
|
||||
const to = (draft.to ?? []).map((r: { email: string }) => r.email).sort();
|
||||
expect(to).toEqual([bob.email, carol.email].sort());
|
||||
|
||||
// UI: opening the draft shows the continue-draft button, which reopens the
|
||||
// composer with both recipients intact.
|
||||
await openFolder(page, { role: 'drafts' });
|
||||
await emailItem(page, subject).first().click();
|
||||
await page.locator('[data-testid="edit-draft"]').click();
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
|
||||
const recips = await composerRecipients(page);
|
||||
expect(recips).toContain(bob.email);
|
||||
expect(recips).toContain(carol.email);
|
||||
});
|
||||
|
||||
test('a recipient typed but not committed to a chip is still saved', async ({ page }) => {
|
||||
const subject = subj('draft-uncommitted');
|
||||
await login(page, alice);
|
||||
|
||||
await openComposer(page);
|
||||
await addRecipient(page, bob.email); // committed chip
|
||||
// Type a second address but do NOT press Enter — leave it as raw input.
|
||||
const input = page.locator('[data-testid="composer-to"] input').first();
|
||||
await input.click();
|
||||
await input.fill(carol.email);
|
||||
await setSubject(page, subject); // blur the To field
|
||||
await draftBody(page, 'uncommitted body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
const drafts = await jmap.mailboxByRole('drafts');
|
||||
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
|
||||
const to = (draft.to ?? []).map((r: { email: string }) => r.email).sort();
|
||||
// Both the committed and the still-in-the-input recipient must survive.
|
||||
expect(to).toEqual([bob.email, carol.email].sort());
|
||||
});
|
||||
|
||||
test('a server-created draft shows the continue-draft button when viewed', async ({ page }) => {
|
||||
const subject = subj('draft-server');
|
||||
await jmap.createDraft(subject, bob.email);
|
||||
|
||||
await login(page, alice);
|
||||
await openFolder(page, { role: 'drafts' });
|
||||
await emailItem(page, subject).first().click();
|
||||
|
||||
// The edit-draft ("continue draft") button must be present for any message
|
||||
// carrying the $draft keyword, regardless of how the draft was created.
|
||||
await expect(page.locator('[data-testid="edit-draft"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a changed sender identity is saved to the draft (server)', async ({ page }) => {
|
||||
const altId = await jmap.ensureIdentity('Alice Team', alice.email);
|
||||
const subject = subj('draft-from');
|
||||
|
||||
await login(page, alice);
|
||||
await openComposer(page);
|
||||
await setFrom(page, altId);
|
||||
await addRecipient(page, bob.email);
|
||||
await setSubject(page, subject);
|
||||
await draftBody(page, 'from-change body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
const drafts = await jmap.mailboxByRole('drafts');
|
||||
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
|
||||
expect((draft.from ?? [])[0]?.name, 'draft From carries the selected identity').toBe('Alice Team');
|
||||
});
|
||||
|
||||
// KNOWN BUG (documented via test.fail): a draft composed with a non-default
|
||||
// identity is saved with the right From on the server (see the test above),
|
||||
// but reopening the draft resets the composer's From selector to the default
|
||||
// identity instead of restoring the one the draft was written with. If this
|
||||
// starts passing, the reopen path was fixed — flip this back to a plain test.
|
||||
test.fail('reopening a draft restores the changed sender in the From selector', async ({ page }) => {
|
||||
const altId = await jmap.ensureIdentity('Alice Team', alice.email);
|
||||
const subject = subj('draft-from-reopen');
|
||||
|
||||
await login(page, alice);
|
||||
await openComposer(page);
|
||||
await setFrom(page, altId);
|
||||
await addRecipient(page, bob.email);
|
||||
await setSubject(page, subject);
|
||||
await draftBody(page, 'reopen body');
|
||||
await waitDraftSaved(page);
|
||||
await closeComposer(page);
|
||||
|
||||
await openFolder(page, { role: 'drafts' });
|
||||
await emailItem(page, subject).first().click();
|
||||
await page.locator('[data-testid="edit-draft"]').click();
|
||||
await expect(page.locator('[data-testid="composer-from"]')).toHaveValue(altId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
login,
|
||||
expandSharedFolders,
|
||||
openFolder,
|
||||
folderMailboxId,
|
||||
moveEmailTo,
|
||||
forceSync,
|
||||
} from './helpers/app';
|
||||
|
||||
/**
|
||||
* Moving mail across the own-account / shared-folder boundary, in both
|
||||
* directions, and between two shared folders. The move is driven from the list
|
||||
* context menu's "Move to" submenu; the authoritative check is the server-side
|
||||
* mailbox the message ends up in, with the reliably-updating (own-account)
|
||||
* counters checked in the UI too.
|
||||
*/
|
||||
const { alice, carol } = ACCOUNTS;
|
||||
const subj = (l: string) => `IT ${l} ${Date.now()}`;
|
||||
|
||||
test.describe('Shared-folder moves', () => {
|
||||
let ja: JmapClient; // owner
|
||||
let jc: JmapClient; // grantee
|
||||
let teamA: string;
|
||||
let teamB: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
ja = await JmapClient.connect(alice.email, alice.password);
|
||||
jc = await JmapClient.connect(carol.email, carol.password);
|
||||
await ja.reset();
|
||||
await jc.reset();
|
||||
teamA = await ja.createSharedFolder('TeamA', carol.email);
|
||||
teamB = await ja.createSharedFolder('TeamB', carol.email);
|
||||
});
|
||||
|
||||
async function seedInto(mailboxId: string, subject: string, owner = ja): Promise<void> {
|
||||
const acct = owner === ja ? alice : carol;
|
||||
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
|
||||
const m = await owner.waitForEmail(subject);
|
||||
await owner.moveEmail(m.id, mailboxId);
|
||||
}
|
||||
|
||||
test('shared folder A -> shared folder B', async ({ page }) => {
|
||||
const s = subj('mv-a2b');
|
||||
await seedInto(teamA, s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { name: 'TeamB', shared: true });
|
||||
await openFolder(page, { name: 'TeamA', shared: true });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
expect(await ja.findEmailBySubject(s, teamB), 'message in TeamB').toBeTruthy();
|
||||
expect(await ja.findEmailBySubject(s, teamA), 'message left TeamA').toBeFalsy();
|
||||
});
|
||||
|
||||
test('shared folder B -> shared folder A', async ({ page }) => {
|
||||
const s = subj('mv-b2a');
|
||||
await seedInto(teamB, s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
|
||||
await openFolder(page, { name: 'TeamB', shared: true });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
expect(await ja.findEmailBySubject(s, teamA), 'message in TeamA').toBeTruthy();
|
||||
expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy();
|
||||
});
|
||||
|
||||
// KNOWN LIMITATION (documented via test.fail): the "Move to" submenu offers a
|
||||
// shared folder as a destination for an own-account message, but clicking it
|
||||
// does NOT relocate the message across the account boundary — it stays put.
|
||||
// Same in reverse (shared -> own). If cross-account moves get implemented,
|
||||
// these will start passing; flip them back to plain tests then.
|
||||
test.fail('own account -> shared folder', async ({ page }) => {
|
||||
const s = subj('mv-own2sh');
|
||||
await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' });
|
||||
await jc.waitForEmail(s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
|
||||
await openFolder(page, { role: 'inbox', shared: false });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Expected (once supported): the message moves to the owner's shared TeamA.
|
||||
expect(await ja.findEmailBySubject(s, teamA), 'message in shared TeamA').toBeTruthy();
|
||||
});
|
||||
|
||||
test.fail('shared folder -> own account', async ({ page }) => {
|
||||
const s = subj('mv-sh2own');
|
||||
await seedInto(teamA, s);
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, alice.email);
|
||||
const dest = await folderMailboxId(page, { role: 'inbox', shared: false });
|
||||
await openFolder(page, { name: 'TeamA', shared: true });
|
||||
await forceSync(page);
|
||||
|
||||
await moveEmailTo(page, s, dest);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Expected (once supported): the message arrives in carol's own Inbox.
|
||||
expect(await jc.findEmailBySubject(s), 'message in own account').toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,52 @@ export async function forceSync(page: Page): Promise<void> {
|
||||
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
|
||||
}
|
||||
|
||||
// ─── Composer / drafts ────────────────────────────────────────────────────
|
||||
|
||||
/** Open the composer via the keyboard shortcut and wait for it to render. */
|
||||
export async function openComposer(page: Page): Promise<void> {
|
||||
await page.keyboard.press('c');
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
|
||||
}
|
||||
|
||||
/** Add a recipient to the To field (commits it as a chip with Enter). */
|
||||
export async function addRecipient(page: Page, email: string): Promise<void> {
|
||||
const input = page.locator('[data-testid="composer-to"] input').first();
|
||||
await input.click();
|
||||
await input.fill(email);
|
||||
await input.press('Enter');
|
||||
}
|
||||
|
||||
/** Select a sending identity in the From dropdown by its identity id. */
|
||||
export async function setFrom(page: Page, identityId: string): Promise<void> {
|
||||
await page.locator('[data-testid="composer-from"]').selectOption({ value: identityId });
|
||||
}
|
||||
|
||||
/** Fill the subject field. */
|
||||
export async function setSubject(page: Page, subject: string): Promise<void> {
|
||||
await page.locator('[data-testid="composer-subject"]').fill(subject);
|
||||
}
|
||||
|
||||
/** Wait until the composer reports the draft as saved. */
|
||||
export async function waitDraftSaved(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="composer-save-status"]')).toHaveAttribute('data-status', 'saved', {
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Close the composer (draft is auto-saved). */
|
||||
export async function closeComposer(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Recipient chips currently shown in the composer's To field. */
|
||||
export async function composerRecipients(page: Page): Promise<string[]> {
|
||||
const to = page.locator('[data-testid="composer-to"]');
|
||||
const text = (await to.innerText()).toLowerCase();
|
||||
return text.split(/\s+/).filter((t) => t.includes('@'));
|
||||
}
|
||||
|
||||
export interface FolderSelector {
|
||||
role?: string;
|
||||
name?: string;
|
||||
@@ -194,6 +240,31 @@ export async function expectFolderUnread(page: Page, sel: FolderSelector, expect
|
||||
.toBe(expected);
|
||||
}
|
||||
|
||||
/** The JMAP (UI) mailbox id backing a folder row — namespaced for shared folders. */
|
||||
export async function folderMailboxId(page: Page, sel: FolderSelector): Promise<string> {
|
||||
const id = await folderRow(page, sel).first().getAttribute('data-mailbox-id');
|
||||
if (!id) throw new Error(`folder ${JSON.stringify(sel)} has no data-mailbox-id`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an email to `destMailboxId` (a UI mailbox id, e.g. from
|
||||
* {@link folderMailboxId}) via the list context menu's "Move to" submenu.
|
||||
*/
|
||||
export async function moveEmailTo(page: Page, subject: string, destMailboxId: string): Promise<void> {
|
||||
const row = emailItem(page, subject).first();
|
||||
await row.waitFor({ state: 'visible' });
|
||||
const submenu = page.locator('[data-testid="ctx-move-to"]');
|
||||
await expect(async () => {
|
||||
await row.click({ button: 'right' });
|
||||
await submenu.waitFor({ state: 'visible', timeout: 2000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
await submenu.hover();
|
||||
const target = page.locator(`[data-testid="move-to:${destMailboxId}"]`);
|
||||
await target.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await target.click();
|
||||
}
|
||||
|
||||
/** Poll until a folder's total count reaches `expected`. */
|
||||
export async function expectFolderTotal(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
|
||||
await expect
|
||||
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
const SUBMISSION = 'urn:ietf:params:jmap:submission';
|
||||
|
||||
/** Rights granted on a shared mailbox (JMAP ACL). */
|
||||
export const FULL_MAILBOX_RIGHTS = {
|
||||
@@ -73,6 +74,29 @@ export class JmapClient {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** All sending identities of this account. */
|
||||
async identities(): Promise<Array<{ id: string; name: string; email: string }>> {
|
||||
const r = await this.request([['Identity/get', { accountId: this.accountId }, '0']], [CORE, SUBMISSION]);
|
||||
return r.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a second sending identity `name <email>` exists (idempotent by
|
||||
* name). Returns its id. Used to make the composer's From selector appear so
|
||||
* a changed sender can be exercised.
|
||||
*/
|
||||
async ensureIdentity(name: string, email: string): Promise<string> {
|
||||
const existing = (await this.identities()).find((i) => i.name === name);
|
||||
if (existing) return existing.id;
|
||||
const r = await this.request(
|
||||
[['Identity/set', { accountId: this.accountId, create: { alt: { name, email, replyTo: null } } }, '0']],
|
||||
[CORE, SUBMISSION],
|
||||
);
|
||||
const created = r.methodResponses[0][1].created?.alt;
|
||||
if (!created) throw new Error(`Identity/set failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Resolve another user's principal id (needed as the key in `shareWith`). */
|
||||
async principalIdByEmail(email: string): Promise<string> {
|
||||
const r = await this.request(
|
||||
@@ -194,6 +218,31 @@ export class JmapClient {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Create a draft message (with the $draft keyword) in the Drafts folder. */
|
||||
async createDraft(subject: string, toEmail: string): Promise<string> {
|
||||
const drafts = await this.mailboxByRole('drafts');
|
||||
if (!drafts) throw new Error('No Drafts mailbox');
|
||||
const r = await this.request([
|
||||
['Email/set', {
|
||||
accountId: this.accountId,
|
||||
create: {
|
||||
d: {
|
||||
mailboxIds: { [drafts.id]: true },
|
||||
keywords: { $draft: true },
|
||||
from: [{ email: this.email }],
|
||||
to: [{ email: toEmail }],
|
||||
subject,
|
||||
bodyValues: { b: { value: 'server-created draft body' } },
|
||||
textBody: [{ partId: 'b', type: 'text/plain' }],
|
||||
},
|
||||
},
|
||||
}, '0'],
|
||||
]);
|
||||
const created = r.methodResponses[0][1].created?.d;
|
||||
if (!created) throw new Error(`createDraft failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Set or clear the $seen keyword on an email. */
|
||||
async setSeen(emailId: string, seen: boolean): Promise<void> {
|
||||
await this.request([
|
||||
@@ -210,7 +259,7 @@ export class JmapClient {
|
||||
['Email/get', {
|
||||
accountId: this.accountId,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'preview'],
|
||||
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'to', 'preview'],
|
||||
}, '1'],
|
||||
]);
|
||||
return r.methodResponses[1][1].list[0];
|
||||
|
||||
Reference in New Issue
Block a user