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
This commit is contained in:
nemunaire 2026-03-25 10:58:34 +07:00
commit 76ee50a100
18 changed files with 53 additions and 40 deletions

View file

@ -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,

View file

@ -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)
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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"

View file

@ -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) {

View file

@ -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) {

View file

@ -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)

View file

@ -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))
}

View file

@ -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),

View file

@ -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