From 578339c400e57aa14dd769551d5453fb575890da Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:14:05 +0200 Subject: [PATCH] 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 and its single-identity fallback. Ref: https://github.com/bulwarkmail/webmail/issues/569 --- components/email/email-composer.tsx | 3 +- integration/stalwart/entrypoint.sh | 25 ++++++++ integration/stalwart/plan-accounts.ndjson.tpl | 1 + integration/tests/04-shared-identity.spec.ts | 59 +++++++++++++++++++ integration/tests/helpers/app.ts | 25 ++++++++ integration/tests/helpers/config.ts | 11 ++++ integration/tests/helpers/jmap.ts | 18 +++++- 7 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 integration/tests/04-shared-identity.spec.ts diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index fb10c866..ae937fc5 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2143,6 +2143,7 @@ export function EmailComposer({ ) : identities.length > 1 ? ( ) : ( - + {subAddressTag ? ( {generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)} diff --git a/integration/stalwart/entrypoint.sh b/integration/stalwart/entrypoint.sh index ea45f91e..61cdc8fc 100755 --- a/integration/stalwart/entrypoint.sh +++ b/integration/stalwart/entrypoint.sh @@ -108,6 +108,31 @@ if [ ! -f "${MARKER}" ]; then "${STALWART_CLI}" apply --file "${PLAN_ACCOUNTS}" --quiet 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 # 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, diff --git a/integration/stalwart/plan-accounts.ndjson.tpl b/integration/stalwart/plan-accounts.ndjson.tpl index 43d6fa48..aaf58263 100644 --- a/integration/stalwart/plan-accounts.ndjson.tpl +++ b/integration/stalwart/plan-accounts.ndjson.tpl @@ -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":{"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":{"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":{"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"}}} diff --git a/integration/tests/04-shared-identity.spec.ts b/integration/tests/04-shared-identity.spec.ts new file mode 100644 index 00000000..a47a7250 --- /dev/null +++ b/integration/tests/04-shared-identity.spec.ts @@ -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); + }); +}); diff --git a/integration/tests/helpers/app.ts b/integration/tests/helpers/app.ts index 2d859c18..7922d4ab 100644 --- a/integration/tests/helpers/app.ts +++ b/integration/tests/helpers/app.ts @@ -169,6 +169,31 @@ export async function openFolder(page: Page, sel: FolderSelector): Promise await folderRow(page, sel).first().click(); } +/** Open the "New message" composer and wait for it to render. */ +export async function openComposer(page: Page): Promise { + 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