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