fix: send a sub-address of your own address from your account, not a shared catch-all

This commit is contained in:
Linus Rath
2026-10-24 17:58:45 +02:00
parent 16e88e8a1b
commit 89e93e330e
2 changed files with 21 additions and 10 deletions
@@ -19,6 +19,7 @@ const IDENTITIES: Record<string, Array<{ id: string; email: string }>> = {
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<string, string>): 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');
+14 -10
View File
@@ -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<string> {
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];
}