From 3035fb046ff60d8a85149ddb633e7ae195fd291a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:46:57 +0200 Subject: [PATCH] feat: surface most severe SPF result and hide "via" badge on spoofed mail --- components/email/email-identity-badge.tsx | 16 +++-- components/email/email-viewer.tsx | 24 +++++++- lib/__tests__/email-headers.test.ts | 61 +++++++++++++++++++ lib/email-headers.ts | 72 +++++++++++++++++++++-- lib/jmap/client.ts | 4 +- lib/jmap/types.ts | 10 ++++ 6 files changed, 173 insertions(+), 14 deletions(-) diff --git a/components/email/email-identity-badge.tsx b/components/email/email-identity-badge.tsx index ce6ed3e6..12dba29d 100644 --- a/components/email/email-identity-badge.tsx +++ b/components/email/email-identity-badge.tsx @@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { Email, Identity } from '@/lib/jmap/types'; import { parseSubAddress } from '@/lib/sub-addressing'; +import { isAuthenticationSpoofed } from '@/lib/email-headers'; import { useSettingsStore } from '@/stores/settings-store'; interface EmailIdentityBadgeProps { @@ -29,10 +30,17 @@ export function EmailIdentityBadge({ // Parse the from address to check for sub-addressing const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter); - // Find matching identity (email sent BY the user) - const matchingIdentity = identities.find( - (identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}` - ); + // Find matching identity (email sent BY the user). When the message is + // likely spoofed, the From address can't be trusted, so we ignore any + // identity match — otherwise a forged From matching one of the user's own + // addresses would render a misleading "via " badge that implies + // legitimacy. + const spoofed = isAuthenticationSpoofed(email.authenticationResults); + const matchingIdentity = spoofed + ? undefined + : identities.find( + (identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}` + ); // Check if email was sent TO a sub-address (received email) let receivedToTag: string | null = null; diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 97c4b816..87b0ae7d 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -4817,9 +4817,27 @@ export function EmailViewer({
{t('details.authentication_security')}
- {auth?.spf && ( - - )} + {auth?.spf && (() => { + // When multiple identities (HELO + MAIL FROM) were + // evaluated, list each result in the tooltip for full + // transparency; the chip itself shows the most severe. + const breakdown = auth.spf.all && auth.spf.all.length > 1 + ? auth.spf.all + .map((r) => { + const label = r.identity === 'mailfrom' ? 'MAIL FROM' : r.identity === 'helo' ? 'HELO' : 'SPF'; + return `${label}: ${translateAuthResult(r.result)}${r.domain ? ` (${r.domain})` : ''}`; + }) + .join('\n') + : null; + return ( + + ); + })()} {auth?.dkim && ( )} diff --git a/lib/__tests__/email-headers.test.ts b/lib/__tests__/email-headers.test.ts index 71655f7e..ce908d81 100644 --- a/lib/__tests__/email-headers.test.ts +++ b/lib/__tests__/email-headers.test.ts @@ -7,6 +7,7 @@ import { getSecurityStatus, parseSpamLLM, extractListHeaders, + isAuthenticationSpoofed, } from '../email-headers'; describe('parseAuthenticationResults', () => { @@ -52,6 +53,66 @@ describe('parseAuthenticationResults', () => { const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com'); expect(result.spf?.result).toBe('softfail'); }); + + it('surfaces the most severe result when multiple SPF identities exist', () => { + // HELO temperror, MAIL FROM fail — the harder fail must be the headline. + const header = + 'mx.example.com; spf=temperror (mx: dns timeout) smtp.helo=mail.spoof.com; spf=fail (mx: not authorized) smtp.mailfrom=victim.com'; + const result = parseAuthenticationResults(header); + expect(result.spf?.result).toBe('fail'); + expect(result.spf?.domain).toBe('victim.com'); + }); + + it('exposes all SPF results when more than one identity is evaluated', () => { + const header = + 'spf=temperror smtp.helo=mail.spoof.com; spf=fail smtp.mailfrom=victim.com'; + const result = parseAuthenticationResults(header); + expect(result.spf?.all).toEqual([ + { result: 'temperror', identity: 'helo', domain: 'mail.spoof.com' }, + { result: 'fail', identity: 'mailfrom', domain: 'victim.com' }, + ]); + }); + + it('does not set `all` for a single SPF result', () => { + const result = parseAuthenticationResults('spf=pass smtp.mailfrom=example.com'); + expect(result.spf?.all).toBeUndefined(); + }); + + it('prefers the MAIL FROM identity when severities tie', () => { + const header = 'spf=pass smtp.helo=mail.example.com; spf=pass smtp.mailfrom=example.com'; + const result = parseAuthenticationResults(header); + expect(result.spf?.domain).toBe('example.com'); + }); +}); + +describe('isAuthenticationSpoofed', () => { + it('returns false when no auth results are present', () => { + expect(isAuthenticationSpoofed(undefined)).toBe(false); + expect(isAuthenticationSpoofed({})).toBe(false); + }); + + it('flags a DMARC fail as spoofed', () => { + expect(isAuthenticationSpoofed({ dmarc: { result: 'fail' } })).toBe(true); + }); + + it('flags a hard SPF fail without a passing DKIM as spoofed', () => { + expect(isAuthenticationSpoofed({ spf: { result: 'fail' } })).toBe(true); + expect( + isAuthenticationSpoofed({ spf: { result: 'fail' }, dkim: { result: 'fail' } }) + ).toBe(true); + }); + + it('does not flag an SPF fail rescued by a passing DKIM', () => { + expect( + isAuthenticationSpoofed({ spf: { result: 'fail' }, dkim: { result: 'pass' } }) + ).toBe(false); + }); + + it('does not flag passing or ambiguous results', () => { + expect(isAuthenticationSpoofed({ spf: { result: 'pass' }, dmarc: { result: 'pass' } })).toBe(false); + expect(isAuthenticationSpoofed({ spf: { result: 'softfail' } })).toBe(false); + expect(isAuthenticationSpoofed({ spf: { result: 'temperror' } })).toBe(false); + }); }); describe('parseSpamScore', () => { diff --git a/lib/email-headers.ts b/lib/email-headers.ts index 652803df..4d0cd390 100644 --- a/lib/email-headers.ts +++ b/lib/email-headers.ts @@ -1,23 +1,83 @@ import { AuthenticationResults } from './jmap/types'; import { parseUnsubscribeUrls } from './validation'; +type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'; +type SpfEntry = NonNullable['all']>[number]; + +/** + * Severity ranking for SPF results. Higher = more severe / more actionable. + * A hard `fail` is a definitive policy violation and must outrank ambiguous + * states like `temperror`, so a spoofed message isn't softened to a + * "temporary failure" headline when one identity hard-fails. + */ +const SPF_SEVERITY: Record = { + fail: 6, + softfail: 5, + permerror: 4, + temperror: 3, + neutral: 2, + none: 1, + pass: 0, +}; + +/** + * Whether the authentication results indicate the visible From identity can't + * be trusted (i.e. the message is likely spoofed). Used to suppress UI that + * would otherwise imply the message legitimately came from one of the user's + * own identities (e.g. the "via " badge). + */ +export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean { + if (!auth) return false; + // DMARC aligns the visible From with SPF/DKIM, so a DMARC fail is the + // strongest single spoofing signal. + if (auth.dmarc?.result === 'fail') return true; + // Otherwise a hard SPF fail with no valid DKIM signature means the sender + // isn't authorized for the envelope domain. + if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') return true; + return false; +} + /** * Parse Authentication-Results header to extract SPF, DKIM, DMARC results */ export function parseAuthenticationResults(header: string): AuthenticationResults { const results: AuthenticationResults = {}; - type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'; type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror'; type DmarcResult = 'pass' | 'fail' | 'none'; type DmarcPolicy = 'reject' | 'quarantine' | 'none'; - // Parse SPF - const spfMatch = header.match(/spf=(\w+)(?:\s+\([^)]*\))?\s+(?:smtp\.(?:mailfrom|helo)=([^\s;]+))?/); - if (spfMatch) { + // Parse SPF. A single Authentication-Results header can carry more than one + // SPF result when the server evaluates multiple identities (HELO and MAIL + // FROM). Collect them all and surface the most severe as the headline so a + // hard MAIL FROM `fail` isn't softened to a HELO `temperror`. + const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g; + const spfResults: SpfEntry[] = []; + let spfM: RegExpExecArray | null; + while ((spfM = spfRegex.exec(header)) !== null) { + spfResults.push({ + result: spfM[1] as SpfResult, + identity: spfM[2] as SpfEntry['identity'], + domain: spfM[3], + }); + } + if (spfResults.length > 0) { + const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1; + // Most severe wins; on a tie prefer the MAIL FROM identity (more meaningful + // than HELO) and otherwise keep the first occurrence. + const primary = spfResults.reduce((best, cur) => { + if (severity(cur.result) > severity(best.result)) return cur; + if ( + severity(cur.result) === severity(best.result) && + best.identity !== 'mailfrom' && + cur.identity === 'mailfrom' + ) return cur; + return best; + }); results.spf = { - result: spfMatch[1] as SpfResult, - domain: spfMatch[2] + result: primary.result, + domain: primary.domain, + ...(spfResults.length > 1 ? { all: spfResults } : {}), }; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 87a92416..54b565ed 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1134,7 +1134,9 @@ export class JMAPClient implements IJMAPClient { const authResultsHeader = headersRecord['Authentication-Results']; if (authResultsHeader) { - const value = Array.isArray(authResultsHeader) ? authResultsHeader[0] : authResultsHeader; + // Multiple Authentication-Results headers (or multiple SPF identities in + // one header) must all be considered so the most severe result wins. + const value = Array.isArray(authResultsHeader) ? authResultsHeader.join('; ') : authResultsHeader; email.authenticationResults = parseAuthenticationResults(value); } diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 45356db4..ec65774b 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -75,6 +75,16 @@ export interface AuthenticationResults { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'; domain?: string; ip?: string; + /** + * All SPF results when the server evaluated multiple identities (HELO and + * MAIL FROM). Present only when more than one result was found; `result` + * above is the most severe of these. + */ + all?: Array<{ + result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'; + domain?: string; + identity?: 'helo' | 'mailfrom'; + }>; }; dkim?: { result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';