diff --git a/go.mod b/go.mod index 4c3a372..8a2e2d9 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.24.6 require ( github.com/getkin/kin-openapi v0.132.0 github.com/gin-gonic/gin v1.11.0 + github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.1.2 + golang.org/x/net v0.42.0 ) require ( @@ -23,7 +25,6 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect - github.com/google/uuid v1.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -49,7 +50,6 @@ require ( golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.27.0 // indirect diff --git a/go.sum b/go.sum index 1dcbee7..033b798 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= diff --git a/internal/analyzer/report.go b/internal/analyzer/report.go new file mode 100644 index 0000000..fe30c6c --- /dev/null +++ b/internal/analyzer/report.go @@ -0,0 +1,348 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "time" + + "git.happydns.org/happyDeliver/internal/api" + "github.com/google/uuid" +) + +// ReportGenerator generates comprehensive deliverability reports +type ReportGenerator struct { + authAnalyzer *AuthenticationAnalyzer + spamAnalyzer *SpamAssassinAnalyzer + dnsAnalyzer *DNSAnalyzer + rblChecker *RBLChecker + contentAnalyzer *ContentAnalyzer + scorer *DeliverabilityScorer +} + +// NewReportGenerator creates a new report generator +func NewReportGenerator( + dnsTimeout time.Duration, + httpTimeout time.Duration, + rbls []string, +) *ReportGenerator { + return &ReportGenerator{ + authAnalyzer: NewAuthenticationAnalyzer(), + spamAnalyzer: NewSpamAssassinAnalyzer(), + dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), + rblChecker: NewRBLChecker(dnsTimeout, rbls), + contentAnalyzer: NewContentAnalyzer(httpTimeout), + scorer: NewDeliverabilityScorer(), + } +} + +// AnalysisResults contains all intermediate analysis results +type AnalysisResults struct { + Email *EmailMessage + Authentication *api.AuthenticationResults + SpamAssassin *SpamAssassinResult + DNS *DNSResults + RBL *RBLResults + Content *ContentResults + Score *ScoringResult +} + +// AnalyzeEmail performs complete email analysis +func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { + results := &AnalysisResults{ + Email: email, + } + + // Run all analyzers + results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) + results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) + results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication) + results.RBL = r.rblChecker.CheckEmail(email) + results.Content = r.contentAnalyzer.AnalyzeContent(email) + + // Calculate overall score + results.Score = r.scorer.CalculateScore( + results.Authentication, + results.SpamAssassin, + results.RBL, + results.Content, + email, + ) + + return results +} + +// GenerateReport creates a complete API report from analysis results +func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report { + reportID := uuid.New() + now := time.Now() + + report := &api.Report{ + Id: reportID, + TestId: testID, + Score: results.Score.OverallScore, + CreatedAt: now, + } + + // Build score summary + report.Summary = &api.ScoreSummary{ + AuthenticationScore: results.Score.AuthScore, + SpamScore: results.Score.SpamScore, + BlacklistScore: results.Score.BlacklistScore, + ContentScore: results.Score.ContentScore, + HeaderScore: results.Score.HeaderScore, + } + + // Collect all checks from different analyzers + checks := []api.Check{} + + // Authentication checks + if results.Authentication != nil { + authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication) + checks = append(checks, authChecks...) + } + + // DNS checks + if results.DNS != nil { + dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS) + checks = append(checks, dnsChecks...) + } + + // RBL checks + if results.RBL != nil { + rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL) + checks = append(checks, rblChecks...) + } + + // SpamAssassin checks + if results.SpamAssassin != nil { + spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin) + checks = append(checks, spamChecks...) + } + + // Content checks + if results.Content != nil { + contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content) + checks = append(checks, contentChecks...) + } + + // Header checks + headerChecks := r.scorer.GenerateHeaderChecks(results.Email) + checks = append(checks, headerChecks...) + + report.Checks = checks + + // Add authentication results + report.Authentication = results.Authentication + + // Add SpamAssassin result + if results.SpamAssassin != nil { + report.Spamassassin = &api.SpamAssassinResult{ + Score: float32(results.SpamAssassin.Score), + RequiredScore: float32(results.SpamAssassin.RequiredScore), + IsSpam: results.SpamAssassin.IsSpam, + } + + if len(results.SpamAssassin.Tests) > 0 { + report.Spamassassin.Tests = &results.SpamAssassin.Tests + } + + if results.SpamAssassin.RawReport != "" { + report.Spamassassin.Report = &results.SpamAssassin.RawReport + } + } + + // Add DNS records + if results.DNS != nil { + dnsRecords := r.buildDNSRecords(results.DNS) + if len(dnsRecords) > 0 { + report.DnsRecords = &dnsRecords + } + } + + // Add blacklist checks + if results.RBL != nil && len(results.RBL.Checks) > 0 { + blacklistChecks := make([]api.BlacklistCheck, 0, len(results.RBL.Checks)) + for _, check := range results.RBL.Checks { + blCheck := api.BlacklistCheck{ + Ip: check.IP, + Rbl: check.RBL, + Listed: check.Listed, + } + if check.Response != "" { + blCheck.Response = &check.Response + } + blacklistChecks = append(blacklistChecks, blCheck) + } + report.Blacklists = &blacklistChecks + } + + // Add raw headers + if results.Email != nil && results.Email.RawHeaders != "" { + report.RawHeaders = &results.Email.RawHeaders + } + + return report +} + +// buildDNSRecords converts DNS analysis results to API DNS records +func (r *ReportGenerator) buildDNSRecords(dns *DNSResults) []api.DNSRecord { + records := []api.DNSRecord{} + + if dns == nil { + return records + } + + // MX records + if len(dns.MXRecords) > 0 { + for _, mx := range dns.MXRecords { + status := api.Found + if !mx.Valid { + if mx.Error != "" { + status = api.Missing + } else { + status = api.Invalid + } + } + + record := api.DNSRecord{ + Domain: dns.Domain, + RecordType: api.MX, + Status: status, + } + + if mx.Host != "" { + value := mx.Host + record.Value = &value + } + + records = append(records, record) + } + } + + // SPF record + if dns.SPFRecord != nil { + status := api.Found + if !dns.SPFRecord.Valid { + if dns.SPFRecord.Record == "" { + status = api.Missing + } else { + status = api.Invalid + } + } + + record := api.DNSRecord{ + Domain: dns.Domain, + RecordType: api.SPF, + Status: status, + } + + if dns.SPFRecord.Record != "" { + record.Value = &dns.SPFRecord.Record + } + + records = append(records, record) + } + + // DKIM records + for _, dkim := range dns.DKIMRecords { + status := api.Found + if !dkim.Valid { + if dkim.Record == "" { + status = api.Missing + } else { + status = api.Invalid + } + } + + record := api.DNSRecord{ + Domain: dkim.Domain, + RecordType: api.DKIM, + Status: status, + } + + if dkim.Record != "" { + // Include selector in value for clarity + value := dkim.Record + record.Value = &value + } + + records = append(records, record) + } + + // DMARC record + if dns.DMARCRecord != nil { + status := api.Found + if !dns.DMARCRecord.Valid { + if dns.DMARCRecord.Record == "" { + status = api.Missing + } else { + status = api.Invalid + } + } + + record := api.DNSRecord{ + Domain: dns.Domain, + RecordType: api.DMARC, + Status: status, + } + + if dns.DMARCRecord.Record != "" { + record.Value = &dns.DMARCRecord.Record + } + + records = append(records, record) + } + + return records +} + +// GenerateRawEmail returns the raw email message as a string +func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string { + if email == nil { + return "" + } + + raw := email.RawHeaders + if email.RawBody != "" { + raw += "\n" + email.RawBody + } + + return raw +} + +// GetRecommendations returns actionable recommendations based on the score +func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string { + if results == nil || results.Score == nil { + return []string{} + } + + return results.Score.Recommendations +} + +// GetScoreSummaryText returns a human-readable score summary +func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string { + if results == nil || results.Score == nil { + return "" + } + + return r.scorer.GetScoreSummary(results.Score) +} diff --git a/internal/analyzer/report_test.go b/internal/analyzer/report_test.go new file mode 100644 index 0000000..4a8fe00 --- /dev/null +++ b/internal/analyzer/report_test.go @@ -0,0 +1,501 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "net/mail" + "net/textproto" + "strings" + "testing" + "time" + + "git.happydns.org/happyDeliver/internal/api" + "github.com/google/uuid" +) + +func TestNewReportGenerator(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + if gen == nil { + t.Fatal("Expected report generator, got nil") + } + + if gen.authAnalyzer == nil { + t.Error("authAnalyzer should not be nil") + } + if gen.spamAnalyzer == nil { + t.Error("spamAnalyzer should not be nil") + } + if gen.dnsAnalyzer == nil { + t.Error("dnsAnalyzer should not be nil") + } + if gen.rblChecker == nil { + t.Error("rblChecker should not be nil") + } + if gen.contentAnalyzer == nil { + t.Error("contentAnalyzer should not be nil") + } + if gen.scorer == nil { + t.Error("scorer should not be nil") + } +} + +func TestAnalyzeEmail(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + + email := createTestEmail() + + results := gen.AnalyzeEmail(email) + + if results == nil { + t.Fatal("Expected analysis results, got nil") + } + + if results.Email == nil { + t.Error("Email should not be nil") + } + + if results.Authentication == nil { + t.Error("Authentication should not be nil") + } + + // SpamAssassin might be nil if headers don't exist + // DNS results should exist + // RBL results should exist + // Content results should exist + + if results.Score == nil { + t.Error("Score should not be nil") + } + + // Verify score is within bounds + if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 { + t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore) + } +} + +func TestGenerateReport(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + testID := uuid.New() + + email := createTestEmail() + results := gen.AnalyzeEmail(email) + + report := gen.GenerateReport(testID, results) + + if report == nil { + t.Fatal("Expected report, got nil") + } + + // Verify required fields + if report.Id == uuid.Nil { + t.Error("Report ID should not be empty") + } + + if report.TestId != testID { + t.Errorf("TestId = %s, want %s", report.TestId, testID) + } + + if report.Score < 0 || report.Score > 10 { + t.Errorf("Score %v is out of bounds", report.Score) + } + + if report.Summary == nil { + t.Error("Summary should not be nil") + } + + if len(report.Checks) == 0 { + t.Error("Checks should not be empty") + } + + // Verify score summary + if report.Summary != nil { + if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 { + t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore) + } + if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 { + t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore) + } + if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 2 { + t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore) + } + if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 2 { + t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore) + } + if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 1 { + t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore) + } + } + + // Verify checks have required fields + for i, check := range report.Checks { + if string(check.Category) == "" { + t.Errorf("Check %d: Category should not be empty", i) + } + if check.Name == "" { + t.Errorf("Check %d: Name should not be empty", i) + } + if string(check.Status) == "" { + t.Errorf("Check %d: Status should not be empty", i) + } + if check.Message == "" { + t.Errorf("Check %d: Message should not be empty", i) + } + } +} + +func TestGenerateReportWithSpamAssassin(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + testID := uuid.New() + + email := createTestEmailWithSpamAssassin() + results := gen.AnalyzeEmail(email) + + report := gen.GenerateReport(testID, results) + + if report.Spamassassin == nil { + t.Error("SpamAssassin result should not be nil") + } + + if report.Spamassassin != nil { + if report.Spamassassin.Score == 0 && report.Spamassassin.RequiredScore == 0 { + t.Error("SpamAssassin scores should be set") + } + } +} + +func TestBuildDNSRecords(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + + tests := []struct { + name string + dns *DNSResults + expectedCount int + expectTypes []api.DNSRecordRecordType + }{ + { + name: "Nil DNS results", + dns: nil, + expectedCount: 0, + }, + { + name: "Complete DNS results", + dns: &DNSResults{ + Domain: "example.com", + MXRecords: []MXRecord{ + {Host: "mail.example.com", Priority: 10, Valid: true}, + }, + SPFRecord: &SPFRecord{ + Record: "v=spf1 include:_spf.example.com -all", + Valid: true, + }, + DKIMRecords: []DKIMRecord{ + { + Selector: "default", + Domain: "example.com", + Record: "v=DKIM1; k=rsa; p=...", + Valid: true, + }, + }, + DMARCRecord: &DMARCRecord{ + Record: "v=DMARC1; p=quarantine", + Valid: true, + }, + }, + expectedCount: 4, // MX, SPF, DKIM, DMARC + expectTypes: []api.DNSRecordRecordType{api.MX, api.SPF, api.DKIM, api.DMARC}, + }, + { + name: "Missing records", + dns: &DNSResults{ + Domain: "example.com", + SPFRecord: &SPFRecord{ + Valid: false, + Error: "No SPF record found", + }, + }, + expectedCount: 1, + expectTypes: []api.DNSRecordRecordType{api.SPF}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + records := gen.buildDNSRecords(tt.dns) + + if len(records) != tt.expectedCount { + t.Errorf("Got %d DNS records, want %d", len(records), tt.expectedCount) + } + + // Verify expected types are present + if tt.expectTypes != nil { + foundTypes := make(map[api.DNSRecordRecordType]bool) + for _, record := range records { + foundTypes[record.RecordType] = true + } + + for _, expectedType := range tt.expectTypes { + if !foundTypes[expectedType] { + t.Errorf("Expected DNS record type %s not found", expectedType) + } + } + } + + // Verify all records have required fields + for i, record := range records { + if record.Domain == "" { + t.Errorf("Record %d: Domain should not be empty", i) + } + if string(record.RecordType) == "" { + t.Errorf("Record %d: RecordType should not be empty", i) + } + if string(record.Status) == "" { + t.Errorf("Record %d: Status should not be empty", i) + } + } + }) + } +} + +func TestGenerateRawEmail(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + + tests := []struct { + name string + email *EmailMessage + expected string + }{ + { + name: "Nil email", + email: nil, + expected: "", + }, + { + name: "Email with headers only", + email: &EmailMessage{ + RawHeaders: "From: sender@example.com\nTo: recipient@example.com\n", + RawBody: "", + }, + expected: "From: sender@example.com\nTo: recipient@example.com\n", + }, + { + name: "Email with headers and body", + email: &EmailMessage{ + RawHeaders: "From: sender@example.com\n", + RawBody: "This is the email body", + }, + expected: "From: sender@example.com\n\nThis is the email body", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := gen.GenerateRawEmail(tt.email) + if raw != tt.expected { + t.Errorf("GenerateRawEmail() = %q, want %q", raw, tt.expected) + } + }) + } +} + +func TestGetRecommendations(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + + tests := []struct { + name string + results *AnalysisResults + expectCount int + }{ + { + name: "Nil results", + results: nil, + expectCount: 0, + }, + { + name: "Results with score", + results: &AnalysisResults{ + Score: &ScoringResult{ + OverallScore: 5.0, + Rating: "Fair", + AuthScore: 1.5, + SpamScore: 1.0, + BlacklistScore: 1.5, + ContentScore: 0.5, + HeaderScore: 0.5, + Recommendations: []string{ + "Improve authentication", + "Fix content issues", + }, + }, + }, + expectCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recs := gen.GetRecommendations(tt.results) + if len(recs) != tt.expectCount { + t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount) + } + }) + } +} + +func TestGetScoreSummaryText(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + + tests := []struct { + name string + results *AnalysisResults + expectEmpty bool + expectString string + }{ + { + name: "Nil results", + results: nil, + expectEmpty: true, + }, + { + name: "Results with score", + results: &AnalysisResults{ + Score: &ScoringResult{ + OverallScore: 8.5, + Rating: "Good", + AuthScore: 2.5, + SpamScore: 1.8, + BlacklistScore: 2.0, + ContentScore: 1.5, + HeaderScore: 0.7, + CategoryBreakdown: map[string]CategoryScore{ + "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"}, + "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"}, + "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"}, + "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"}, + "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"}, + }, + }, + }, + expectEmpty: false, + expectString: "8.5/10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + summary := gen.GetScoreSummaryText(tt.results) + if tt.expectEmpty { + if summary != "" { + t.Errorf("Expected empty summary, got %q", summary) + } + } else { + if summary == "" { + t.Error("Expected non-empty summary") + } + if tt.expectString != "" && !strings.Contains(summary, tt.expectString) { + t.Errorf("Summary should contain %q, got %q", tt.expectString, summary) + } + } + }) + } +} + +func TestReportCategories(t *testing.T) { + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs) + testID := uuid.New() + + email := createComprehensiveTestEmail() + results := gen.AnalyzeEmail(email) + report := gen.GenerateReport(testID, results) + + // Verify all check categories are present + categories := make(map[api.CheckCategory]bool) + for _, check := range report.Checks { + categories[check.Category] = true + } + + expectedCategories := []api.CheckCategory{ + api.Authentication, + api.Dns, + api.Headers, + } + + for _, cat := range expectedCategories { + if !categories[cat] { + t.Errorf("Expected category %s not found in checks", cat) + } + } +} + +// Helper functions + +func createTestEmail() *EmailMessage { + header := make(mail.Header) + header[textproto.CanonicalMIMEHeaderKey("From")] = []string{"sender@example.com"} + header[textproto.CanonicalMIMEHeaderKey("To")] = []string{"recipient@example.com"} + header[textproto.CanonicalMIMEHeaderKey("Subject")] = []string{"Test Email"} + header[textproto.CanonicalMIMEHeaderKey("Date")] = []string{"Mon, 01 Jan 2024 12:00:00 +0000"} + header[textproto.CanonicalMIMEHeaderKey("Message-ID")] = []string{""} + + return &EmailMessage{ + Header: header, + From: &mail.Address{Address: "sender@example.com"}, + To: []*mail.Address{{Address: "recipient@example.com"}}, + Subject: "Test Email", + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{ + { + ContentType: "text/plain", + Content: "This is a test email", + IsText: true, + }, + }, + RawHeaders: "From: sender@example.com\nTo: recipient@example.com\nSubject: Test Email\nDate: Mon, 01 Jan 2024 12:00:00 +0000\nMessage-ID: \n", + RawBody: "This is a test email", + } +} + +func createTestEmailWithSpamAssassin() *EmailMessage { + email := createTestEmail() + email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Status")] = []string{"No, score=2.3 required=5.0"} + email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Score")] = []string{"2.3"} + email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"} + return email +} + +func createComprehensiveTestEmail() *EmailMessage { + email := createTestEmailWithSpamAssassin() + + // Add authentication headers + email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{ + "example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass", + } + + // Add HTML content + email.Parts = append(email.Parts, MessagePart{ + ContentType: "text/html", + Content: "

Test

Link", + IsHTML: true, + }) + + return email +}