diff --git a/README.md b/README.md index 4c4013b..3c213cd 100644 --- a/README.md +++ b/README.md @@ -166,24 +166,7 @@ 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. -#### 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. +Choose one of the following way to integrate happyDeliver in your existing setup: #### Postfix LMTP Transport diff --git a/docker/README.md b/docker/README.md index 2199eeb..3769365 100644 --- a/docker/README.md +++ b/docker/README.md @@ -110,38 +110,14 @@ 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 -### Receiver Hostname +Note that the hostname of the container is used to filter the authentication tests results. -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): +Example: ```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:** diff --git a/go.mod b/go.mod index 038eb22..9902a87 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ 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 @@ -16,7 +15,6 @@ 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 @@ -26,6 +24,7 @@ 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 10c9b72..5863eca 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ 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= @@ -102,7 +98,6 @@ 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= @@ -170,7 +165,6 @@ 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= diff --git a/internal/config/cli.go b/internal/config/cli.go index 3a426bf..3accc99 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -34,7 +34,6 @@ 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 37e4314..468a2aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,11 +34,6 @@ 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 @@ -63,7 +58,6 @@ type EmailConfig struct { Domain string TestAddressPrefix string LMTPAddr string - ReceiverHostname string } // AnalysisConfig contains timeout and behavior settings for email analysis @@ -90,7 +84,6 @@ 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 f06f535..062a091 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -98,17 +98,6 @@ 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 3793218..a16829b 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -41,7 +41,6 @@ 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 2051a56..07f7794 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -28,13 +28,11 @@ import ( ) // AuthenticationAnalyzer analyzes email authentication results -type AuthenticationAnalyzer struct { - receiverHostname string -} +type AuthenticationAnalyzer struct{} // NewAuthenticationAnalyzer creates a new authentication analyzer -func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer { - return &AuthenticationAnalyzer{receiverHostname: receiverHostname} +func NewAuthenticationAnalyzer() *AuthenticationAnalyzer { + return &AuthenticationAnalyzer{} } // AnalyzeAuthentication extracts and analyzes authentication results from email headers @@ -42,7 +40,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api results := &api.AuthenticationResults{} // Parse Authentication-Results headers - authHeaders := email.GetAuthenticationResults(a.receiverHostname) + authHeaders := email.GetAuthenticationResults() 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 7f2f99e..9269d70 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 7cb9c85..b1b5468 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 3218639..2aab530 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 3b8fb08..d7fda84 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 5b46995..d0529b5 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 960aef5..7a84c49 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 7122f53..27901b5 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 0fdd69d..220ac39 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 f9704c0..be29a08 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/dns.go b/pkg/analyzer/dns.go index 10babb0..3098934 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, headersResults *api.HeaderAnalysis) *api.DNSResults { +func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults { // Extract domain from From address if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" { return &api.DNSResults{ @@ -104,14 +104,19 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.Header // SPF validates the MAIL FROM command, which corresponds to Return-Path results.SpfRecords = d.checkSPFRecords(spfDomain) - // 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) + // 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) + } } - *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) } } diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go index 1a8a199..7ac858d 100644 --- a/pkg/analyzer/dns_dkim.go +++ b/pkg/analyzer/dns_dkim.go @@ -29,38 +29,6 @@ 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 45da53c..8d94d20 100644 --- a/pkg/analyzer/dns_dkim_test.go +++ b/pkg/analyzer/dns_dkim_test.go @@ -26,220 +26,6 @@ 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/parser.go b/pkg/analyzer/parser.go index 00de151..5b30e07 100644 --- a/pkg/analyzer/parser.go +++ b/pkg/analyzer/parser.go @@ -28,9 +28,16 @@ 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 @@ -211,18 +218,18 @@ func buildRawHeaders(header mail.Header) string { } // GetAuthenticationResults extracts Authentication-Results headers -// If receiverHostname is provided, only returns headers that begin with that hostname -func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string { +// If hostname is provided, only returns headers that begin with that hostname +func (e *EmailMessage) GetAuthenticationResults() []string { allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] // If no hostname specified, return all results - if receiverHostname == "" { + if hostname == "" { return allResults } // Filter results that begin with the specified hostname var filtered []string - prefix := receiverHostname + ";" + prefix := hostname + ";" 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 196e8e2..eb1fc6a 100644 --- a/pkg/analyzer/parser_test.go +++ b/pkg/analyzer/parser_test.go @@ -106,6 +106,9 @@ 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 @@ -120,7 +123,7 @@ Body content. t.Fatalf("Failed to parse email: %v", err) } - authResults := email.GetAuthenticationResults("example.com") + authResults := email.GetAuthenticationResults() 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 78d70f7..bd12960 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -43,7 +43,6 @@ type ReportGenerator struct { // NewReportGenerator creates a new report generator func NewReportGenerator( - receiverHostname string, dnsTimeout time.Duration, httpTimeout time.Duration, rbls []string, @@ -51,7 +50,7 @@ func NewReportGenerator( checkAllIPs bool, ) *ReportGenerator { return &ReportGenerator{ - authAnalyzer: NewAuthenticationAnalyzer(receiverHostname), + authAnalyzer: NewAuthenticationAnalyzer(), spamAnalyzer: NewSpamAssassinAnalyzer(), rspamdAnalyzer: NewRspamdAnalyzer(), dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), @@ -84,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.Headers) + results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) results.DNSWL = r.dnswlChecker.CheckEmail(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index dd76213..82e923e 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 diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go index 3fed81d..f3f548b 100644 --- a/pkg/analyzer/rspamd.go +++ b/pkg/analyzer/rspamd.go @@ -51,13 +51,6 @@ 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 d6ae961..7964af2 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -45,14 +45,6 @@ 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), } diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 46a4d2d..93531e7 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -13,12 +13,6 @@ 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": @@ -103,28 +97,6 @@ - {#if allRequiredMissing} -
-
- - No authentication results found. -

- This usually means either: -

- -
-
- {/if}
{#if authentication.iprev}