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
+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];