diff --git a/src/lib/__tests__/email-headers.test.ts b/src/lib/__tests__/email-headers.test.ts index 6ff67182..286769e6 100644 --- a/src/lib/__tests__/email-headers.test.ts +++ b/src/lib/__tests__/email-headers.test.ts @@ -85,6 +85,16 @@ describe('getSenderVerification', () => { expect(getSenderVerification(undefined, 'a@bank.example')).toBeNull(); }); + it('takes the signing domain from header.i when the receiver writes no header.d (bulwarkmail/webmail#1420)', () => { + const from = 'noreply@example.org'; + expect(getSenderVerification(parseAuthenticationResults('mx.example.net; dkim=pass header.i=@example.org'), from)).toBeNull(); + expect(getSenderVerification(parseAuthenticationResults('mx; dkim=pass header.i=bounce@mail.example.org'), from)).toBeNull(); + expect(getSenderVerification(parseAuthenticationResults('mx; dkim=pass header.i=@other.example'), from)) + .toEqual({ status: 'unverified', domain: 'example.org' }); + // header.d still wins when both are there. + expect(parseAuthenticationResults('mx; dkim=pass header.d=a.example header.i=@b.example').dkim?.domain).toBe('a.example'); + }); + it('does not count a HELO pass', () => { const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=bank.example; spf=none smtp.mailfrom=bank.example'); expect(getSenderVerification(auth, 'a@bank.example')?.status).toBe('unverified'); diff --git a/src/lib/email-headers.ts b/src/lib/email-headers.ts index bb3856ad..539265ea 100644 --- a/src/lib/email-headers.ts +++ b/src/lib/email-headers.ts @@ -323,6 +323,12 @@ function parseResinfos(header: string): ResInfo[] { .filter((info): info is ResInfo => info !== null); } +/** The domain of a DKIM identity (`header.i`): `@domain` or `user@domain`. */ +function identityDomain(identity: string | undefined): string | undefined { + const at = identity?.lastIndexOf('@') ?? -1; + return identity && at >= 0 && at < identity.length - 1 ? identity.slice(at + 1) : undefined; +} + const DMARC_SEVERITY: Record = { fail: 3, permerror: 2, @@ -394,7 +400,9 @@ export function parseAuthenticationResults(headers: string | readonly string[]): .filter((info) => info.method === 'dkim') .map((info) => ({ result: info.result as DkimResult, - domain: info.props['header.d'], + // Some receivers name the signature only by its identity (`header.i`, + // `@domain` or `user@domain`, RFC 8601 2.7.1); its domain is the signer's. + domain: info.props['header.d'] ?? identityDomain(info.props['header.i']), selector: info.props['header.s'], })); if (dkimResults.length > 0) {