From d2f9f1a1a47fbdca013ec6c2612982f1fb8b376e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:09:49 +0100 Subject: [PATCH] fix: send from a shared mailbox through its own identity for the from address #1450 --- lib/__tests__/jmap-send-group-account.test.ts | 40 ++++++++++++++++++- lib/jmap/client.ts | 17 +++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/jmap-send-group-account.test.ts b/lib/__tests__/jmap-send-group-account.test.ts index a6b9cb2ed..7bd9648ae 100644 --- a/lib/__tests__/jmap-send-group-account.test.ts +++ b/lib/__tests__/jmap-send-group-account.test.ts @@ -48,7 +48,7 @@ interface Recorded { identityId?: string; } -function mockServer(): Recorded { +function mockServer(identities = IDENTITIES): Recorded { const rec: Recorded = {}; vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { const { methodCalls } = JSON.parse((init as { body: string }).body) as { methodCalls: MethodCall[] }; @@ -60,7 +60,7 @@ function mockServer(): Recorded { { id: `${accountId}-sent`, name: 'Sent', role: 'sent' }, ] }, callId]; } - if (name === 'Identity/get') return [name, { list: IDENTITIES[accountId] ?? [] }, callId]; + if (name === 'Identity/get') return [name, { list: identities[accountId] ?? [] }, callId]; if (name === 'Email/set') { if (args.create) { rec.emailSetAccount = accountId; @@ -154,6 +154,42 @@ describe('JMAPClient.sendEmail picks the account that owns the From identity (#1 expectAccount(rec, 'grp'); }); + // #1450: the composer's identity ids are the member's own. In the group + // account the same id can be another of the group's addresses, and the + // server takes MAIL FROM from the identity the submission names. + describe('identity ids of the member’s account (#1450)', () => { + const COLLIDING: Record> = { + me: [ + { id: 'by', email: 'info@radar.example' }, + { id: 'bw', email: '*@lists.example' }, + { id: 'c', email: 'carol@example.org' }, + ], + grp: [ + { id: 'by', email: 'info@alias.example' }, + { id: 'bw', email: 'info@alias.example' }, + { id: 'b7', email: 'info@radar.example' }, + { id: 'w', email: '*@lists.example' }, + ], + }; + + it.each([ + ['the From address', 'info@radar.example', 'by', 'b7'], + ['a sub-addressed From', 'info+news@radar.example', 'by', 'b7'], + ['a From covered by a wildcard identity', 'announce@lists.example', 'bw', 'w'], + ])('submits through the group’s identity for %s', async (_case, fromEmail, memberId, groupId) => { + const rec = mockServer(COLLIDING); + await send(createClient({ me: 'carol@example.org', grp: 'team' }), fromEmail, memberId); + expectAccount(rec, 'grp'); + expect(rec.identityId).toBe(groupId); + }); + + it('keeps the id when it names the From address in the group account too', async () => { + const rec = mockServer(); + await send(createClient({ me: 'carol@example.org', grp: 'team' }), 'team@example.org', 'b'); + expect(rec.identityId).toBe('b'); + }); + }); + describe('drafts', () => { const draft = (client: JMAPClient, fromEmail: string, draftId?: string, options?: { accountId?: string; previousDraftAccountId?: string }) => client.createDraft(['rcpt@example.net'], 'Subject', 'body', undefined, undefined, 'b', fromEmail, draftId, diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index ea94d4071..02e562a71 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -4865,13 +4865,28 @@ export class JMAPClient implements IJMAPClient { ? identities.find((id) => id.email.toLowerCase() === requestedMailFrom.toLowerCase()) : undefined; if (!matchingIdentity && identityId) { - matchingIdentity = identities.find((id) => id.id === identityId); + // Identity ids are per account, and the composer's come from the + // user's own account. In a shared account the same id can be + // another of its addresses, and the server derives MAIL FROM from + // that identity (#1450): there the id only counts when it names + // the From address. + const byId = identities.find((id) => id.id === identityId); + if (byId && (targetAccountId === this.accountId || !fromEmail || emailMatchesLogin(byId.email, fromEmail))) { + matchingIdentity = byId; + } } if (!matchingIdentity) { // Addresses compared as the server does: regardless of case. const target = fromEmail || this.username; matchingIdentity = identities.find((id) => emailMatchesLogin(id.email, target)); } + if (!matchingIdentity && fromEmail) { + // A sub-addressed From or one covered by a wildcard identity, the + // other ways resolveSendAccountId picks an account. + const from = parseRecipientString(fromEmail).email.toLowerCase(); + matchingIdentity = identities.find((id) => identityEmailIsSubAddressOf(id.email, from)) + ?? identities.find((id) => identityEmailIsWildcardFor(id.email, from)); + } finalIdentityId = matchingIdentity?.id || identities[0].id; identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo; identityEmail = (matchingIdentity || identities[0]).email;