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
@@ -2143,6 +2143,7 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
) : identities.length > 1 ? (
|
) : identities.length > 1 ? (
|
||||||
<select
|
<select
|
||||||
|
data-testid="composer-from"
|
||||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||||
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
||||||
@@ -2174,7 +2175,7 @@ export function EmailComposer({
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-foreground flex-1 truncate">
|
<span data-testid="composer-from" className="text-sm text-foreground flex-1 truncate">
|
||||||
{subAddressTag ? (
|
{subAddressTag ? (
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
|
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
|
||||||
|
|||||||
@@ -108,6 +108,31 @@ if [ ! -f "${MARKER}" ]; then
|
|||||||
"${STALWART_CLI}" apply --file "${PLAN_ACCOUNTS}" --quiet
|
"${STALWART_CLI}" apply --file "${PLAN_ACCOUNTS}" --quiet
|
||||||
rm -f "${PLAN_ACCOUNTS}"
|
rm -f "${PLAN_ACCOUNTS}"
|
||||||
|
|
||||||
|
# Make 'carol' a member of the 'team' group *before her first login*, so she
|
||||||
|
# already has the shared group mailbox (its folders show under "Shared") and
|
||||||
|
# the team@ send-as identity. This provisions the issue #569 scenario: the
|
||||||
|
# composer's From dropdown should then offer the group address. Membership is
|
||||||
|
# a set keyed by the group's server-assigned account id (see the User schema's
|
||||||
|
# memberGroupIds), so the ids are resolved here, mirroring DOMAIN_ID above.
|
||||||
|
# carol (not alice/bob) is used so the single-/multi-account sync specs, which
|
||||||
|
# drive alice and bob, keep a clean unshared environment.
|
||||||
|
q_account_id() {
|
||||||
|
STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
|
||||||
|
"${STALWART_CLI}" query Account --json 2>/dev/null \
|
||||||
|
| grep "\"emailAddress\":\"$1\"" \
|
||||||
|
| sed -E 's/.*"id":"([^"]+)".*/\1/'
|
||||||
|
}
|
||||||
|
CAROL_ID=$(q_account_id "carol@example.org")
|
||||||
|
TEAM_ID=$(q_account_id "team@example.org")
|
||||||
|
if [ -n "${CAROL_ID}" ] && [ -n "${TEAM_ID}" ]; then
|
||||||
|
log "Adding carol (${CAROL_ID}) to the team group (${TEAM_ID}) [issue #569]"
|
||||||
|
STALWART_URL=${LOCAL_URL} STALWART_USER=admin STALWART_PASSWORD=${ADMIN_PASS} \
|
||||||
|
"${STALWART_CLI}" update Account "${CAROL_ID}" \
|
||||||
|
--field "memberGroupIds={\"${TEAM_ID}\":true}" >/dev/null
|
||||||
|
else
|
||||||
|
log "WARNING: could not resolve account ids (carol='${CAROL_ID}' team='${TEAM_ID}'); skipping group membership"
|
||||||
|
fi
|
||||||
|
|
||||||
# Default inbound throttles (sender->recipient + sender-IP) otherwise trip
|
# Default inbound throttles (sender->recipient + sender-IP) otherwise trip
|
||||||
# 452 4.4.5 when a test blasts many messages. Stalwart re-seeds the defaults
|
# 452 4.4.5 when a test blasts many messages. Stalwart re-seeds the defaults
|
||||||
# on every start when absent, so deleting is useless; disable them instead,
|
# on every start when absent, so deleting is useless; disable them instead,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{"@type":"create","object":"Account","value":{"alice":{"@type":"User","name":"alice","domainId":"${DOMAIN_ID}","description":"Integration test mailbox alice","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
|
{"@type":"create","object":"Account","value":{"alice":{"@type":"User","name":"alice","domainId":"${DOMAIN_ID}","description":"Integration test mailbox alice","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
|
||||||
{"@type":"create","object":"Account","value":{"bob":{"@type":"User","name":"bob","domainId":"${DOMAIN_ID}","description":"Integration test mailbox bob","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
|
{"@type":"create","object":"Account","value":{"bob":{"@type":"User","name":"bob","domainId":"${DOMAIN_ID}","description":"Integration test mailbox bob","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
|
||||||
{"@type":"create","object":"Account","value":{"carol":{"@type":"User","name":"carol","domainId":"${DOMAIN_ID}","description":"Integration test mailbox carol","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
|
{"@type":"create","object":"Account","value":{"carol":{"@type":"User","name":"carol","domainId":"${DOMAIN_ID}","description":"Integration test mailbox carol","credentials":{"0":{"@type":"Password","secret":"${TEST_ACCOUNT_PASSWORD}"}}}}}
|
||||||
|
{"@type":"create","object":"Account","value":{"team":{"@type":"Group","name":"team","domainId":"${DOMAIN_ID}","description":"Team shared mailbox"}}}
|
||||||
{"@type":"create","object":"NetworkListener","value":{"submission":{"name":"submission","protocol":"smtp","bind":{"[::]:587":true},"tlsImplicit":false,"useTls":false,"socketReuseAddress":true,"socketNoDelay":true}}}
|
{"@type":"create","object":"NetworkListener","value":{"submission":{"name":"submission","protocol":"smtp","bind":{"[::]:587":true},"tlsImplicit":false,"useTls":false,"socketReuseAddress":true,"socketNoDelay":true}}}
|
||||||
{"@type":"create","object":"NetworkListener","value":{"imap":{"name":"imap","protocol":"imap","bind":{"[::]:143":true},"tlsImplicit":false,"useTls":false,"socketReuseAddress":true,"socketNoDelay":true}}}
|
{"@type":"create","object":"NetworkListener","value":{"imap":{"name":"imap","protocol":"imap","bind":{"[::]:143":true},"tlsImplicit":false,"useTls":false,"socketReuseAddress":true,"socketNoDelay":true}}}
|
||||||
{"@type":"update","object":"MtaStageAuth","value":{"saslMechanisms":{"match":{"0":{"if":"local_port != 25","then":"[plain, login, oauthbearer, xoauth2]"}},"else":"false"}}}
|
{"@type":"update","object":"MtaStageAuth","value":{"saslMechanisms":{"match":{"0":{"if":"local_port != 25","then":"[plain, login, oauthbearer, xoauth2]"}},"else":"false"}}}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { ACCOUNTS, GROUP } from './helpers/config';
|
||||||
|
import { JmapClient } from './helpers/jmap';
|
||||||
|
import { login, forceSync, openComposer, composerFromOptions } from './helpers/app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #569 — the composer's "From" dropdown should include identities from
|
||||||
|
* shared/group accounts, not only the logged-in (connected) accounts.
|
||||||
|
*
|
||||||
|
* Scenario under test (the one expected to already work): a Stalwart *group*
|
||||||
|
* mailbox `team@example.org` is provisioned and `carol` is made a member of it
|
||||||
|
* *before her first login* (integration/stalwart/plan-accounts.ndjson.tpl +
|
||||||
|
* entrypoint.sh). As a member she gets the group's shared folders (shown under
|
||||||
|
* "Shared") and — per Stalwart — a `team@` send-as identity (Stalwart returns
|
||||||
|
* it among the member's own account identities). The composer should therefore
|
||||||
|
* offer `team@example.org` as a sender alongside her own address.
|
||||||
|
*/
|
||||||
|
const member = ACCOUNTS[GROUP.team.memberOf];
|
||||||
|
const { team } = GROUP;
|
||||||
|
|
||||||
|
test.describe('Composer From: shared/group identities (issue #569)', () => {
|
||||||
|
test('the pre-provisioned group account is reachable in the member’s JMAP session', async () => {
|
||||||
|
// Server-side guard for the UI expectation below: if this fails, the
|
||||||
|
// bootstrap group provisioning is broken (not the app). The member must see
|
||||||
|
// the group account in her session, and it must expose a team@ identity she
|
||||||
|
// can send as.
|
||||||
|
const memberClient = await JmapClient.connect(member.email, member.password);
|
||||||
|
expect(memberClient.sharedAccountNames()).toContain(team.email);
|
||||||
|
|
||||||
|
const groupAccountId = Object.entries(memberClient.accounts).find(
|
||||||
|
([, name]) => name === team.email,
|
||||||
|
)?.[0];
|
||||||
|
expect(groupAccountId).toBeTruthy();
|
||||||
|
|
||||||
|
const res = await memberClient.request([
|
||||||
|
['Identity/get', { accountId: groupAccountId! }, '0'],
|
||||||
|
]);
|
||||||
|
const groupIdentityEmails = (res.methodResponses[0][1].list as { email: string }[]).map(
|
||||||
|
(i) => i.email,
|
||||||
|
);
|
||||||
|
expect(groupIdentityEmails).toContain(team.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the composer From selector offers the group address', async ({ page }) => {
|
||||||
|
await login(page, member);
|
||||||
|
// Shared accounts/identities are discovered from the JMAP session; give the
|
||||||
|
// client a beat to settle them after the first render.
|
||||||
|
await forceSync(page);
|
||||||
|
|
||||||
|
await openComposer(page);
|
||||||
|
|
||||||
|
// The group address alice can send as should be one of the From choices.
|
||||||
|
// If #569 is unaddressed the control collapses to her own address only and
|
||||||
|
// this poll times out — which is the point: it pins the expected behaviour.
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await composerFromOptions(page)).join(' | '), { timeout: 15000 })
|
||||||
|
.toContain(team.email);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -169,6 +169,31 @@ export async function openFolder(page: Page, sel: FolderSelector): Promise<void>
|
|||||||
await folderRow(page, sel).first().click();
|
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. */
|
/** Locator for an email row by (exact) subject. */
|
||||||
export function emailItem(page: Page, subject: string): Locator {
|
export function emailItem(page: Page, subject: string): Locator {
|
||||||
return page.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`);
|
return page.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`);
|
||||||
|
|||||||
@@ -43,3 +43,14 @@ export const ACCOUNTS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AccountKey = keyof typeof ACCOUNTS;
|
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 CORE = 'urn:ietf:params:jmap:core';
|
||||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
const MAIL = 'urn:ietf:params:jmap:mail';
|
||||||
|
// Identity/* lives under the submission capability, not mail.
|
||||||
|
const SUBMISSION = 'urn:ietf:params:jmap:submission';
|
||||||
|
|
||||||
interface JmapMailbox {
|
interface JmapMailbox {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,6 +30,9 @@ export class JmapClient {
|
|||||||
private authHeader: string;
|
private authHeader: string;
|
||||||
private apiUrl: string;
|
private apiUrl: string;
|
||||||
accountId = '';
|
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) {
|
private constructor(private email: string, password: string) {
|
||||||
this.authHeader = 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
|
this.authHeader = 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
|
||||||
@@ -46,14 +51,25 @@ export class JmapClient {
|
|||||||
const primary = session.primaryAccounts?.[MAIL];
|
const primary = session.primaryAccounts?.[MAIL];
|
||||||
if (!primary) throw new Error(`No mail account for ${email} in JMAP session`);
|
if (!primary) throw new Error(`No mail account for ${email} in JMAP session`);
|
||||||
c.accountId = primary;
|
c.accountId = primary;
|
||||||
|
c.accounts = Object.fromEntries(
|
||||||
|
Object.entries(session.accounts ?? {}).map(([id, a]) => [id, (a as { name: string }).name]),
|
||||||
|
);
|
||||||
return c;
|
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> {
|
async request(methodCalls: MethodCall[]): Promise<any> {
|
||||||
const res = await fetch(this.apiUrl, {
|
const res = await fetch(this.apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: this.authHeader, 'Content-Type': 'application/json' },
|
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()}`);
|
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
Reference in New Issue
Block a user