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:
committed by
Linus Rath
parent
8d8bc7cb13
commit
578339c400
@@ -169,6 +169,31 @@ export async function openFolder(page: Page, sel: FolderSelector): Promise<void>
|
||||
await folderRow(page, sel).first().click();
|
||||
}
|
||||
|
||||
/** Open the "New message" composer and wait for it to render. */
|
||||
export async function openComposer(page: Page): Promise<Locator> {
|
||||
await page.locator('[data-tour="compose-button"]').first().click();
|
||||
const composer = page.locator('[data-testid="email-composer"]');
|
||||
await composer.waitFor({ state: 'visible', timeout: 15000 });
|
||||
return composer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sender addresses the composer's From control offers.
|
||||
*
|
||||
* With more than one identity the control is a <select> and each choice is an
|
||||
* <option>; with a single identity it collapses to a static <span> that shows
|
||||
* only that address. Returning the raw text of whichever is rendered lets a
|
||||
* test assert on the *set of senders* without caring which shape it took.
|
||||
*/
|
||||
export async function composerFromOptions(page: Page): Promise<string[]> {
|
||||
const from = page.locator('[data-testid="composer-from"]').first();
|
||||
await from.waitFor({ state: 'visible', timeout: 10000 });
|
||||
if ((await from.locator('option').count()) > 0) {
|
||||
return from.locator('option').allTextContents();
|
||||
}
|
||||
return [await from.innerText()];
|
||||
}
|
||||
|
||||
/** Locator for an email row by (exact) subject. */
|
||||
export function emailItem(page: Page, subject: string): Locator {
|
||||
return page.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`);
|
||||
|
||||
@@ -43,3 +43,14 @@ export const ACCOUNTS = {
|
||||
} as const;
|
||||
|
||||
export type AccountKey = keyof typeof ACCOUNTS;
|
||||
|
||||
/**
|
||||
* The shared *group* account provisioned by the bootstrap (a Stalwart Group
|
||||
* principal, not a login). `carol` is made a member before her first login, so
|
||||
* she sees the group's folders under "Shared" and can send as its address.
|
||||
* Groups have no password of their own — access is via a member's session.
|
||||
* (carol, rather than alice/bob, keeps the sync specs' accounts unshared.)
|
||||
*/
|
||||
export const GROUP = {
|
||||
team: { user: 'team', email: `team@${DOMAIN}`, memberOf: 'carol' as AccountKey },
|
||||
} as const;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user