test(integration): composer From offers shared/group identities (#569)

Provision a Stalwart group (team@example.org) with carol as a member before her
first login, and assert the composer's From selector offers the group address.
This confirms the group-membership scenario of #569 already works out of the
box: Stalwart returns the group's send-as identity on the member's own account,
so the app's normal single-account identity load surfaces it (identities.length
> 1 -> the From <select> renders with team@).

- stalwart: create the `team` Group in plan-accounts and add carol via
  User.memberGroupIds in the entrypoint (id resolved after apply, like
  DOMAIN_ID). carol, not alice/bob, so the sync specs stay unshared.
- helpers: GROUP config, openComposer/composerFromOptions, and JmapClient
  accounts + sharedAccountNames (Identity/get needs the submission capability).
- composer: add data-testid="composer-from" to the From <select> and its
  single-identity <span> fallback.

Ref: https://github.com/bulwarkmail/webmail/issues/569
This commit is contained in:
Stefan Hildebrandt
2026-07-16 17:59:52 +02:00
committed by Linus Rath
parent 8d8bc7cb13
commit 578339c400
7 changed files with 140 additions and 2 deletions
+17 -1
View File
@@ -12,6 +12,8 @@ import { JMAP_URL } from './config';
const CORE = 'urn:ietf:params:jmap:core';
const MAIL = 'urn:ietf:params:jmap:mail';
// Identity/* lives under the submission capability, not mail.
const SUBMISSION = 'urn:ietf:params:jmap:submission';
interface JmapMailbox {
id: string;
@@ -28,6 +30,9 @@ export class JmapClient {
private authHeader: string;
private apiUrl: string;
accountId = '';
/** Every account visible in this user's session (own + shared/group),
* keyed by accountId -> account name (its email address). */
accounts: Record<string, string> = {};
private constructor(private email: string, password: string) {
this.authHeader = 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
@@ -46,14 +51,25 @@ export class JmapClient {
const primary = session.primaryAccounts?.[MAIL];
if (!primary) throw new Error(`No mail account for ${email} in JMAP session`);
c.accountId = primary;
c.accounts = Object.fromEntries(
Object.entries(session.accounts ?? {}).map(([id, a]) => [id, (a as { name: string }).name]),
);
return c;
}
/** Names (email addresses) of the shared/group accounts this user can access,
* i.e. everything in the session except the user's own primary account. */
sharedAccountNames(): string[] {
return Object.entries(this.accounts)
.filter(([id]) => id !== this.accountId)
.map(([, name]) => name);
}
async request(methodCalls: MethodCall[]): 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: [CORE, MAIL, SUBMISSION], methodCalls }),
});
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
return res.json();