From c96a8b92b8171890cb4272c3e0b15700c5ee69d2 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 11:28:53 +0700 Subject: [PATCH 1/6] Readd missing go deps --- go.mod | 3 ++- go.sum | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 9902a87..038eb22 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/emersion/go-smtp v0.24.0 + github.com/getkin/kin-openapi v0.133.0 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.3.0 @@ -15,6 +16,7 @@ require ( ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -24,7 +26,6 @@ require ( github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect - github.com/getkin/kin-openapi v0.133.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect diff --git a/go.sum b/go.sum index 5863eca..10c9b72 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI= github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -98,6 +102,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -165,6 +170,7 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= From 71e08324162ae26e225db25752d2c777bbf47075 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 10:27:14 +0700 Subject: [PATCH 2/6] Parse DKIM-Signature headers directly in AnalyzeDNS Remove authResults parameter from AnalyzeDNS, making it independent of the authentication analysis step. Instead, parse DKIM-Signature headers directly to extract domain and selector. Bug: https://github.com/happyDomain/happydeliver/issues/11 --- pkg/analyzer/dns.go | 21 ++-- pkg/analyzer/dns_dkim.go | 32 +++++ pkg/analyzer/dns_dkim_test.go | 214 ++++++++++++++++++++++++++++++++++ pkg/analyzer/report.go | 2 +- 4 files changed, 255 insertions(+), 14 deletions(-) diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 3098934..10babb0 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -54,7 +54,7 @@ func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DN } // AnalyzeDNS performs DNS validation for the email's domain -func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults { +func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.HeaderAnalysis) *api.DNSResults { // Extract domain from From address if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" { return &api.DNSResults{ @@ -104,19 +104,14 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic // SPF validates the MAIL FROM command, which corresponds to Return-Path results.SpfRecords = d.checkSPFRecords(spfDomain) - // Check DKIM records (from authentication results) - // DKIM can be for any domain, but typically the From domain - if authResults != nil && authResults.Dkim != nil { - for _, dkim := range *authResults.Dkim { - if dkim.Domain != nil && dkim.Selector != nil { - dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector) - if dkimRecord != nil { - if results.DkimRecords == nil { - results.DkimRecords = new([]api.DKIMRecord) - } - *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) - } + // Check DKIM records by parsing DKIM-Signature headers directly + for _, sig := range parseDKIMSignatures(email.Header["DKIM-Signature"]) { + dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector) + if dkimRecord != nil { + if results.DkimRecords == nil { + results.DkimRecords = new([]api.DKIMRecord) } + *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) } } diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go index 7ac858d..1a8a199 100644 --- a/pkg/analyzer/dns_dkim.go +++ b/pkg/analyzer/dns_dkim.go @@ -29,6 +29,38 @@ import ( "git.happydns.org/happyDeliver/internal/api" ) +// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header. +type DKIMHeader struct { + Domain string + Selector string +} + +// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values. +func parseDKIMSignatures(signatures []string) []DKIMHeader { + var results []DKIMHeader + for _, sig := range signatures { + var domain, selector string + for _, part := range strings.Split(sig, ";") { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + continue + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "d": + domain = val + case "s": + selector = val + } + } + if domain != "" && selector != "" { + results = append(results, DKIMHeader{Domain: domain, Selector: selector}) + } + } + return results +} + // checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { // DKIM records are at: selector._domainkey.domain diff --git a/pkg/analyzer/dns_dkim_test.go b/pkg/analyzer/dns_dkim_test.go index 8d94d20..45da53c 100644 --- a/pkg/analyzer/dns_dkim_test.go +++ b/pkg/analyzer/dns_dkim_test.go @@ -26,6 +26,220 @@ import ( "time" ) +func TestParseDKIMSignatures(t *testing.T) { + tests := []struct { + name string + signatures []string + expected []DKIMHeader + }{ + { + name: "Empty input", + signatures: nil, + expected: nil, + }, + { + name: "Empty string", + signatures: []string{""}, + expected: nil, + }, + { + name: "Simple Gmail-style", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`, + }, + expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}}, + }, + { + name: "Microsoft 365 style", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`, + }, + expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}}, + }, + { + name: "Tab-folded multiline (Postfix-style)", + signatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==", + }, + expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}}, + }, + { + name: "Space-folded multiline (RFC-style)", + signatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==", + }, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}}, + }, + { + name: "d= and s= on separate continuation lines", + signatures: []string{ + "v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==", + }, + expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}}, + }, + { + name: "No space after semicolons", + signatures: []string{ + `v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`, + }, + expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}}, + }, + { + name: "Multiple spaces after semicolons", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}}, + }, + { + name: "Ed25519 signature (RFC 8463)", + signatures: []string{ + "v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==", + }, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}}, + }, + { + name: "Multiple signatures (ESP double-signing)", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mydomain.com; s=mail; h=from:to:subject; bh=hash1=; b=sig1==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`, + }, + expected: []DKIMHeader{ + {Domain: "mydomain.com", Selector: "mail"}, + {Domain: "sendib.com", Selector: "mail"}, + }, + }, + { + name: "Dual-algorithm signing (Ed25519 + RSA, same domain, different selectors)", + signatures: []string{ + `v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; s=brisbane; h=from:to:subject; bh=hash=; b=edSig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`, + }, + expected: []DKIMHeader{ + {Domain: "football.example.com", Selector: "brisbane"}, + {Domain: "football.example.com", Selector: "test"}, + }, + }, + { + name: "Amazon SES long selectors", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/simple; d=amazonses.com; s=224i4yxa5dv7c2xz3womw6peuabd; h=from:to:subject; bh=sesHash=; b=sesSig==`, + `v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`, + }, + expected: []DKIMHeader{ + {Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"}, + {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"}, + }, + }, + { + name: "Subdomain in d=", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}}, + }, + { + name: "Deeply nested subdomain", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}}, + }, + { + name: "Selector with hyphens (Microsoft 365 custom domain style)", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}}, + }, + { + name: "Selector with dots", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}}, + }, + { + name: "Single-character selector", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}}, + }, + { + name: "Postmark-style timestamp selector, s= before d=", + signatures: []string{ + `v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`, + }, + expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}}, + }, + { + name: "d= and s= at the very end", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`, + }, + expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}}, + }, + { + name: "Full tag set", + signatures: []string{ + `v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}}, + }, + { + name: "Missing d= tag", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; s=selector1; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Missing s= tag", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Missing both d= and s= tags", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Mix of valid and invalid signatures", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=good.com; s=sel1; h=from:to; bh=hash=; b=sig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; s=orphan; h=from:to; bh=hash=; b=sig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{ + {Domain: "good.com", Selector: "sel1"}, + {Domain: "also-good.com", Selector: "sel2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseDKIMSignatures(tt.signatures) + if len(result) != len(tt.expected) { + t.Fatalf("parseDKIMSignatures() returned %d results, want %d\n got: %+v\n want: %+v", len(result), len(tt.expected), result, tt.expected) + } + for i := range tt.expected { + if result[i].Domain != tt.expected[i].Domain { + t.Errorf("result[%d].Domain = %q, want %q", i, result[i].Domain, tt.expected[i].Domain) + } + if result[i].Selector != tt.expected[i].Selector { + t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector) + } + } + }) + } +} + func TestValidateDKIM(t *testing.T) { tests := []struct { name string diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index bd12960..354f911 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -83,7 +83,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { // Run all analyzers results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) - results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers) + results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) results.DNSWL = r.dnswlChecker.CheckEmail(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) From 76ee50a1001689c69483b44d6f21f527149f977d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 10:58:34 +0700 Subject: [PATCH 3/6] Make receiver hostname configurable via --receiver-hostname flag Remove the package-level global hostname from parser.go. Adds a log warning when the last Received hop doesn't match the expected receiver hostname. Bug: https://github.com/happyDomain/happydeliver/issues/11 --- internal/config/cli.go | 1 + internal/config/config.go | 7 +++++++ internal/receiver/receiver.go | 11 +++++++++++ pkg/analyzer/analyzer.go | 1 + pkg/analyzer/authentication.go | 10 ++++++---- pkg/analyzer/authentication_arc_test.go | 4 ++-- pkg/analyzer/authentication_bimi_test.go | 2 +- pkg/analyzer/authentication_dkim_test.go | 2 +- pkg/analyzer/authentication_dmarc_test.go | 2 +- pkg/analyzer/authentication_iprev_test.go | 4 ++-- pkg/analyzer/authentication_spf_test.go | 4 ++-- pkg/analyzer/authentication_test.go | 6 +++--- .../authentication_x_aligned_from_test.go | 4 ++-- pkg/analyzer/authentication_x_google_dkim_test.go | 2 +- pkg/analyzer/parser.go | 15 ++++----------- pkg/analyzer/parser_test.go | 5 +---- pkg/analyzer/report.go | 3 ++- pkg/analyzer/report_test.go | 10 +++++----- 18 files changed, 53 insertions(+), 40 deletions(-) diff --git a/internal/config/cli.go b/internal/config/cli.go index 3accc99..3a426bf 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -34,6 +34,7 @@ func declareFlags(o *Config) { flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address") + flag.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())") flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query") flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") diff --git a/internal/config/config.go b/internal/config/config.go index 468a2aa..37e4314 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,6 +34,11 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +func getHostname() string { + h, _ := os.Hostname() + return h +} + // Config represents the application configuration type Config struct { DevProxy string @@ -58,6 +63,7 @@ type EmailConfig struct { Domain string TestAddressPrefix string LMTPAddr string + ReceiverHostname string } // AnalysisConfig contains timeout and behavior settings for email analysis @@ -84,6 +90,7 @@ func DefaultConfig() *Config { Domain: "happydeliver.local", TestAddressPrefix: "test-", LMTPAddr: "127.0.0.1:2525", + ReceiverHostname: getHostname(), }, Analysis: AnalysisConfig{ DNSTimeout: 5 * time.Second, diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index 062a091..f06f535 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -98,6 +98,17 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score) + // Warn if the last Received hop doesn't match the expected receiver hostname + if r.config.Email.ReceiverHostname != "" && + result.Report.HeaderAnalysis != nil && + result.Report.HeaderAnalysis.ReceivedChain != nil && + len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 { + lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0] + if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname { + log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname) + } + } + // Marshal report to JSON reportJSON, err := json.Marshal(result.Report) if err != nil { diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index a16829b..3793218 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -41,6 +41,7 @@ type EmailAnalyzer struct { // NewEmailAnalyzer creates a new email analyzer with the given configuration func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { generator := NewReportGenerator( + cfg.Email.ReceiverHostname, cfg.Analysis.DNSTimeout, cfg.Analysis.HTTPTimeout, cfg.Analysis.RBLs, diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 07f7794..2051a56 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -28,11 +28,13 @@ import ( ) // AuthenticationAnalyzer analyzes email authentication results -type AuthenticationAnalyzer struct{} +type AuthenticationAnalyzer struct { + receiverHostname string +} // NewAuthenticationAnalyzer creates a new authentication analyzer -func NewAuthenticationAnalyzer() *AuthenticationAnalyzer { - return &AuthenticationAnalyzer{} +func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer { + return &AuthenticationAnalyzer{receiverHostname: receiverHostname} } // AnalyzeAuthentication extracts and analyzes authentication results from email headers @@ -40,7 +42,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api results := &api.AuthenticationResults{} // Parse Authentication-Results headers - authHeaders := email.GetAuthenticationResults() + authHeaders := email.GetAuthenticationResults(a.receiverHostname) for _, header := range authHeaders { a.parseAuthenticationResultsHeader(header, results) } diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go index 9269d70..7f2f99e 100644 --- a/pkg/analyzer/authentication_arc_test.go +++ b/pkg/analyzer/authentication_arc_test.go @@ -50,7 +50,7 @@ func TestParseARCResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -136,7 +136,7 @@ func TestValidateARCChain(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go index b1b5468..7cb9c85 100644 --- a/pkg/analyzer/authentication_bimi_test.go +++ b/pkg/analyzer/authentication_bimi_test.go @@ -64,7 +64,7 @@ func TestParseBIMIResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go index 2aab530..3218639 100644 --- a/pkg/analyzer/authentication_dkim_test.go +++ b/pkg/analyzer/authentication_dkim_test.go @@ -58,7 +58,7 @@ func TestParseDKIMResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go index d7fda84..3b8fb08 100644 --- a/pkg/analyzer/authentication_dmarc_test.go +++ b/pkg/analyzer/authentication_dmarc_test.go @@ -48,7 +48,7 @@ func TestParseDMARCResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go index d0529b5..5b46995 100644 --- a/pkg/analyzer/authentication_iprev_test.go +++ b/pkg/analyzer/authentication_iprev_test.go @@ -93,7 +93,7 @@ func TestParseIPRevResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -181,7 +181,7 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go index 7a84c49..960aef5 100644 --- a/pkg/analyzer/authentication_spf_test.go +++ b/pkg/analyzer/authentication_spf_test.go @@ -60,7 +60,7 @@ func TestParseSPFResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -161,7 +161,7 @@ func TestParseLegacySPF(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 27901b5..7122f53 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -100,7 +100,7 @@ func TestGetAuthenticationScore(t *testing.T) { }, } - scorer := NewAuthenticationAnalyzer() + scorer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -247,7 +247,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -353,7 +353,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { // This test verifies that only the first occurrence of each auth method is parsed - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) { header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com" diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go index 220ac39..0fdd69d 100644 --- a/pkg/analyzer/authentication_x_aligned_from_test.go +++ b/pkg/analyzer/authentication_x_aligned_from_test.go @@ -66,7 +66,7 @@ func TestParseXAlignedFromResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -126,7 +126,7 @@ func TestCalculateXAlignedFromScore(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go index be29a08..f9704c0 100644 --- a/pkg/analyzer/authentication_x_google_dkim_test.go +++ b/pkg/analyzer/authentication_x_google_dkim_test.go @@ -60,7 +60,7 @@ func TestParseXGoogleDKIMResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/parser.go b/pkg/analyzer/parser.go index 5b30e07..00de151 100644 --- a/pkg/analyzer/parser.go +++ b/pkg/analyzer/parser.go @@ -28,16 +28,9 @@ import ( "mime/multipart" "net/mail" "net/textproto" - "os" "strings" ) -var hostname = "" - -func init() { - hostname, _ = os.Hostname() -} - // EmailMessage represents a parsed email message type EmailMessage struct { Header mail.Header @@ -218,18 +211,18 @@ func buildRawHeaders(header mail.Header) string { } // GetAuthenticationResults extracts Authentication-Results headers -// If hostname is provided, only returns headers that begin with that hostname -func (e *EmailMessage) GetAuthenticationResults() []string { +// If receiverHostname is provided, only returns headers that begin with that hostname +func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string { allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] // If no hostname specified, return all results - if hostname == "" { + if receiverHostname == "" { return allResults } // Filter results that begin with the specified hostname var filtered []string - prefix := hostname + ";" + prefix := receiverHostname + ";" for _, result := range allResults { // Trim whitespace and check if it starts with hostname; trimmed := strings.TrimSpace(result) diff --git a/pkg/analyzer/parser_test.go b/pkg/analyzer/parser_test.go index eb1fc6a..196e8e2 100644 --- a/pkg/analyzer/parser_test.go +++ b/pkg/analyzer/parser_test.go @@ -106,9 +106,6 @@ Content-Type: text/html; charset=utf-8 } func TestGetAuthenticationResults(t *testing.T) { - // Force hostname - hostname = "example.com" - rawEmail := `From: sender@example.com To: recipient@example.com Subject: Test Email @@ -123,7 +120,7 @@ Body content. t.Fatalf("Failed to parse email: %v", err) } - authResults := email.GetAuthenticationResults() + authResults := email.GetAuthenticationResults("example.com") if len(authResults) != 2 { t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults)) } diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 354f911..78d70f7 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -43,6 +43,7 @@ type ReportGenerator struct { // NewReportGenerator creates a new report generator func NewReportGenerator( + receiverHostname string, dnsTimeout time.Duration, httpTimeout time.Duration, rbls []string, @@ -50,7 +51,7 @@ func NewReportGenerator( checkAllIPs bool, ) *ReportGenerator { return &ReportGenerator{ - authAnalyzer: NewAuthenticationAnalyzer(), + authAnalyzer: NewAuthenticationAnalyzer(receiverHostname), spamAnalyzer: NewSpamAssassinAnalyzer(), rspamdAnalyzer: NewRspamdAnalyzer(), dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index 82e923e..dd76213 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -32,7 +32,7 @@ import ( ) func TestNewReportGenerator(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) if gen == nil { t.Fatal("Expected report generator, got nil") } @@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) { } func TestAnalyzeEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) email := createTestEmail() @@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) { } func TestGenerateReport(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) testID := uuid.New() email := createTestEmail() @@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) { } func TestGenerateReportWithSpamAssassin(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) testID := uuid.New() email := createTestEmailWithSpamAssassin() @@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) { } func TestGenerateRawEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) tests := []struct { name string From 26025c96a223ae30dbe4327c1724d637f11167e2 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 11:10:47 +0700 Subject: [PATCH 4/6] Document --receiver-hostname flag and HAPPYDELIVER_RECEIVER_HOSTNAME env var Explain how happyDeliver filters Authentication-Results headers by hostname, how to find the correct authserv-id value, and when to override it (especially when bypassing the embedded Postfix). Bug: https://github.com/happyDomain/happydeliver/issues/1 Bug: https://github.com/happyDomain/happydeliver/issues/11 --- README.md | 19 ++++++++++++++++++- docker/README.md | 28 ++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3c213cd..4c4013b 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,24 @@ The server will start on `http://localhost:8080` by default. It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ... happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations. -Choose one of the following way to integrate happyDeliver in your existing setup: +#### Receiver Hostname + +happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`). + +If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly: + +```bash +./happyDeliver server -receiver-hostname mail.example.com +``` + +Or via environment variable: +```bash +HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server +``` + +**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`. + +If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. #### Postfix LMTP Transport diff --git a/docker/README.md b/docker/README.md index 3769365..2199eeb 100644 --- a/docker/README.md +++ b/docker/README.md @@ -110,14 +110,38 @@ Default configuration for the Docker environment: The container accepts these environment variables: - `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local) +- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below) +- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP -Note that the hostname of the container is used to filter the authentication tests results. +### Receiver Hostname -Example: +happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`). + +In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically. + +**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname: + +```bash +docker run -d \ + -e HAPPYDELIVER_DOMAIN=example.com \ + -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \ + ... +``` + +To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`. + +If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. + +Example (all-in-one, no override needed): ```bash docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ... ``` +Example (external MTA integration): +```bash +docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ... +``` + ## Volumes **Required volumes:** From 2fcee1b8857c8c2a991d22fd30bfe0a41f271c88 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 11:29:36 +0700 Subject: [PATCH 5/6] Return nil from spam analyzers when primary headers are missing Bug: https://github.com/happyDomain/happydeliver/issues/11 --- pkg/analyzer/rspamd.go | 7 +++++++ pkg/analyzer/spamassassin.go | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go index f3f548b..3fed81d 100644 --- a/pkg/analyzer/rspamd.go +++ b/pkg/analyzer/rspamd.go @@ -51,6 +51,13 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult { return nil } + // Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report + _, hasSpamdResult := headers["X-Spamd-Result"] + _, hasRspamdScore := headers["X-Rspamd-Score"] + if !hasSpamdResult && !hasRspamdScore { + return nil + } + result := &api.RspamdResult{ Symbols: make(map[string]api.RspamdSymbol), } diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index 7964af2..d6ae961 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -45,6 +45,14 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa return nil } + // Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report + _, hasStatus := headers["X-Spam-Status"] + _, hasScore := headers["X-Spam-Score"] + _, hasFlag := headers["X-Spam-Flag"] + if !hasStatus && !hasScore && !hasFlag { + return nil + } + result := &api.SpamAssassinResult{ TestDetails: make(map[string]api.SpamTestDetail), } From 35fc997390c22e92408108efb15435b5d7b229ea Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 12:11:18 +0700 Subject: [PATCH 6/6] Add warning banner when all authentication results are missing Explains the two most common causes: the mail server not being configured to verify email authentication, or a receiver hostname mismatch with --receiver-hostname. Bug: https://github.com/happyDomain/happydeliver/issues/11 --- .../lib/components/AuthenticationCard.svelte | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 93531e7..46a4d2d 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -13,6 +13,12 @@ let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props(); + let allRequiredMissing = $derived( + !authentication.spf && + (!authentication.dkim || authentication.dkim.length === 0) && + !authentication.dmarc, + ); + function getAuthResultClass(result: string, noneIsFail: boolean): string { switch (result) { case "pass": @@ -97,6 +103,28 @@ + {#if allRequiredMissing} +
+
+ + No authentication results found. +

+ This usually means either: +

+
    +
  • + The receiving mail server is not configured to verify email authentication + (no Authentication-Results header was found in the message). +
  • +
  • + The Authentication-Results header exists but the receiver + hostname does not match the configured + --receiver-hostname value. +
  • +
+
+
+ {/if}
{#if authentication.iprev}