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:
Stefan Hildebrandt
2026-07-11 21:15:27 +02:00
parent e05fbb2fe9
commit 060c5d00d1
9 changed files with 420 additions and 4 deletions
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+1
View File
@@ -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')}
>
+4
View File
@@ -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
View File
@@ -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
+146
View File
@@ -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);
});
});
+119
View File
@@ -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();
});
});
+71
View File
@@ -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
+50 -1
View File
@@ -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];