From 76ee50a1001689c69483b44d6f21f527149f977d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 10:58:34 +0700 Subject: [PATCH] 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