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:
parent
71e0832416
commit
76ee50a100
18 changed files with 53 additions and 40 deletions
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue