From 89e93e330e4bf6141007c696c0012894d8332194 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:58:45 +0200 Subject: [PATCH] fix: send a sub-address of your own address from your account, not a shared catch-all --- lib/__tests__/jmap-send-group-account.test.ts | 7 ++++++ lib/jmap/client.ts | 24 +++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/lib/__tests__/jmap-send-group-account.test.ts b/lib/__tests__/jmap-send-group-account.test.ts index 4a6b7a22e..a6b9cb2ed 100644 --- a/lib/__tests__/jmap-send-group-account.test.ts +++ b/lib/__tests__/jmap-send-group-account.test.ts @@ -19,6 +19,7 @@ const IDENTITIES: Record> = { grp: [{ id: 'b', email: 'team@example.org' }, { id: 'x', email: 'info@example.org' }], alice: [{ id: 'a', email: 'alice@example.org' }], wild: [{ id: 'w', email: '*@lists.example.org' }], + catchall: [{ id: 'k', email: '*@example.org' }], }; function createClient(accounts: Record): JMAPClient { @@ -141,6 +142,12 @@ describe('JMAPClient.sendEmail picks the account that owns the From identity (#1 expectAccount(sub, 'me'); }); + it('keeps a sub-address of the member’s own address over a shared catch-all identity', async () => { + const rec = mockServer(); + await send(createClient({ me: 'carol-renamed', catchall: 'catchall' }), 'carol+x@example.org', 'c'); + expectAccount(rec, 'me'); + }); + it('still prefers an account named exactly like the From address', async () => { const rec = mockServer(); await send(createClient({ me: 'carol@example.org', grp: 'team@example.org' }), 'team@example.org'); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 0f8fe7952..b77e683ff 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1027,20 +1027,22 @@ function identityEmailMatchesExactly(identityEmail: string | undefined, from: st return !!identityEmail && identityEmail.toLowerCase() === from; } -/** - * An identity address that covers `from` without being it: a sub-address of - * it (team+tag@ for team@, with the common "+" / "-" delimiters) or a - * wildcard identity for its domain (RFC 8621 §6, "*@example.com"). - */ -function identityEmailCovers(identityEmail: string | undefined, from: string): boolean { +/** An identity address `from` is a sub-address of (team+tag@ for team@, with the common "+" / "-" delimiters). */ +function identityEmailIsSubAddressOf(identityEmail: string | undefined, from: string): boolean { if (!identityEmail) return false; const [idLocal, idDomain] = identityEmail.toLowerCase().split('@'); const [fromLocal, fromDomain] = from.split('@'); - if (!idDomain || !fromDomain || idDomain !== fromDomain) return false; - if (idLocal === '*') return true; + if (!idDomain || !fromDomain || idDomain !== fromDomain || idLocal === '*') return false; return fromLocal.startsWith(`${idLocal}+`) || fromLocal.startsWith(`${idLocal}-`); } +/** A wildcard identity for the domain of `from` (RFC 8621 §6, "*@example.com"). */ +function identityEmailIsWildcardFor(identityEmail: string | undefined, from: string): boolean { + if (!identityEmail) return false; + const [idLocal, idDomain] = identityEmail.toLowerCase().split('@'); + return idLocal === '*' && !!idDomain && idDomain === from.split('@')[1]; +} + export class JMAPClient implements IJMAPClient { private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000; /** @@ -5413,9 +5415,11 @@ export class JMAPClient implements IJMAPClient { * through the member's account and its Sent copy landed there. * * Order: an account named exactly `fromEmail`; then an account that owns an - * identity for the address - exact before sub-address/wildcard, and a + * identity for the address - exact, then sub-address, then wildcard, and a * delegated (group/shared) account before the primary one, since the * primary may merely mirror the group's identity; else the primary account. + * A sub-address of the member's own address stays theirs even when a + * shared catch-all account has a wildcard identity for the domain. */ async resolveSendAccountId(fromEmail?: string): Promise { if (!fromEmail) return this.accountId; @@ -5456,7 +5460,7 @@ export class JMAPClient implements IJMAPClient { } } - for (const matches of [identityEmailMatchesExactly, identityEmailCovers]) { + for (const matches of [identityEmailMatchesExactly, identityEmailIsSubAddressOf, identityEmailIsWildcardFor]) { const hit = identitiesByAccount.find(([, list]) => list.some(identity => matches(identity.email, from))); if (hit) return hit[0]; }