Build report generation system with JSON output
This commit is contained in:
parent
900c951f8f
commit
338a07a577
4 changed files with 853 additions and 4 deletions
4
go.mod
4
go.mod
|
|
@ -5,7 +5,9 @@ go 1.24.6
|
||||||
require (
|
require (
|
||||||
github.com/getkin/kin-openapi v0.132.0
|
github.com/getkin/kin-openapi v0.132.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/oapi-codegen/runtime v1.1.2
|
github.com/oapi-codegen/runtime v1.1.2
|
||||||
|
golang.org/x/net v0.42.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -23,7 +25,6 @@ require (
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // 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/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // 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/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
golang.org/x/mod v0.25.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/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.27.0 // indirect
|
golang.org/x/text v0.27.0 // indirect
|
||||||
|
|
|
||||||
4
go.sum
4
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/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/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/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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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/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/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=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
|
|
||||||
348
internal/analyzer/report.go
Normal file
348
internal/analyzer/report.go
Normal file
|
|
@ -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 <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
501
internal/analyzer/report_test.go
Normal file
501
internal/analyzer/report_test.go
Normal file
|
|
@ -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 <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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{"<test123@example.com>"}
|
||||||
|
|
||||||
|
return &EmailMessage{
|
||||||
|
Header: header,
|
||||||
|
From: &mail.Address{Address: "sender@example.com"},
|
||||||
|
To: []*mail.Address{{Address: "recipient@example.com"}},
|
||||||
|
Subject: "Test Email",
|
||||||
|
MessageID: "<test123@example.com>",
|
||||||
|
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: <test123@example.com>\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: "<html><body><p>Test</p><a href='https://example.com'>Link</a></body></html>",
|
||||||
|
IsHTML: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return email
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue