happyDeliver/pkg/analyzer/report.go
Pierre-Olivier Mercier ff013fe694 refactor: handle DNS whitelists
Introduce a single DNSListChecker struct with flags to avoid code
duplication with already existing RBL checker.
2026-03-07 16:18:12 +07:00

306 lines
9.1 KiB
Go

// 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"
"git.happydns.org/happyDeliver/internal/utils"
"github.com/google/uuid"
)
// ReportGenerator generates comprehensive deliverability reports
type ReportGenerator struct {
authAnalyzer *AuthenticationAnalyzer
spamAnalyzer *SpamAssassinAnalyzer
rspamdAnalyzer *RspamdAnalyzer
dnsAnalyzer *DNSAnalyzer
rblChecker *DNSListChecker
dnswlChecker *DNSListChecker
contentAnalyzer *ContentAnalyzer
headerAnalyzer *HeaderAnalyzer
}
// NewReportGenerator creates a new report generator
func NewReportGenerator(
dnsTimeout time.Duration,
httpTimeout time.Duration,
rbls []string,
dnswls []string,
checkAllIPs bool,
) *ReportGenerator {
return &ReportGenerator{
authAnalyzer: NewAuthenticationAnalyzer(),
spamAnalyzer: NewSpamAssassinAnalyzer(),
rspamdAnalyzer: NewRspamdAnalyzer(),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
contentAnalyzer: NewContentAnalyzer(httpTimeout),
headerAnalyzer: NewHeaderAnalyzer(),
}
}
// AnalysisResults contains all intermediate analysis results
type AnalysisResults struct {
Email *EmailMessage
Authentication *api.AuthenticationResults
Content *ContentResults
DNS *api.DNSResults
Headers *api.HeaderAnalysis
RBL *DNSListResults
DNSWL *DNSListResults
SpamAssassin *api.SpamAssassinResult
Rspamd *api.RspamdResult
}
// 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.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
results.RBL = r.rblChecker.CheckEmail(email)
results.DNSWL = r.dnswlChecker.CheckEmail(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
results.Content = r.contentAnalyzer.AnalyzeContent(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: utils.UUIDToBase32(reportID),
TestId: utils.UUIDToBase32(testID),
CreatedAt: now,
}
// Calculate scores directly from analyzers (no more checks array)
dnsScore := 0
var dnsGrade string
if results.DNS != nil {
// Extract sender IP from received chain for FCrDNS verification
var senderIP string
if results.Headers != nil && results.Headers.ReceivedChain != nil && len(*results.Headers.ReceivedChain) > 0 {
firstHop := (*results.Headers.ReceivedChain)[0]
if firstHop.Ip != nil {
senderIP = *firstHop.Ip
}
}
dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS, senderIP)
}
authScore := 0
var authGrade string
if results.Authentication != nil {
authScore, authGrade = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication)
}
contentScore := 0
var contentGrade string
if results.Content != nil {
contentScore, contentGrade = r.contentAnalyzer.CalculateContentScore(results.Content)
}
headerScore := 0
var headerGrade rune
if results.Headers != nil {
headerScore, headerGrade = r.headerAnalyzer.CalculateHeaderScore(results.Headers)
}
blacklistScore := 0
var blacklistGrade string
if results.RBL != nil {
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL)
}
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
rspamdScore, rspamdGrade := r.rspamdAnalyzer.CalculateRspamdScore(results.Rspamd)
// Combine SpamAssassin and rspamd scores 50/50.
// If only one filter ran (the other returns "" grade), use that filter's score alone.
var spamScore int
var spamGrade string
switch {
case saGrade == "" && rspamdGrade == "":
spamScore = 0
spamGrade = ""
case saGrade == "":
spamScore = rspamdScore
spamGrade = rspamdGrade
case rspamdGrade == "":
spamScore = saScore
spamGrade = saGrade
default:
spamScore = (saScore + rspamdScore) / 2
spamGrade = MinGrade(saGrade, rspamdGrade)
}
report.Summary = &api.ScoreSummary{
DnsScore: dnsScore,
DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade),
AuthenticationScore: authScore,
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
BlacklistScore: blacklistScore,
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
ContentScore: contentScore,
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
HeaderScore: headerScore,
HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade),
SpamScore: spamScore,
SpamGrade: api.ScoreSummarySpamGrade(spamGrade),
}
// Add authentication results
report.Authentication = results.Authentication
// Add content analysis
if results.Content != nil {
contentAnalysis := r.contentAnalyzer.GenerateContentAnalysis(results.Content)
report.ContentAnalysis = contentAnalysis
}
// Add DNS records
if results.DNS != nil {
report.DnsResults = results.DNS
}
// Add headers results
report.HeaderAnalysis = results.Headers
// Add blacklist checks as a map of IP -> array of BlacklistCheck
if results.RBL != nil && len(results.RBL.Checks) > 0 {
report.Blacklists = &results.RBL.Checks
}
// Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only)
if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 {
report.Whitelists = &results.DNSWL.Checks
}
// Add SpamAssassin result with individual deliverability score
if results.SpamAssassin != nil {
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)
results.SpamAssassin.DeliverabilityScore = api.PtrTo(saScore)
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
}
report.Spamassassin = results.SpamAssassin
// Add rspamd result with individual deliverability score
if results.Rspamd != nil {
rspamdGradeTyped := api.RspamdResultDeliverabilityGrade(rspamdGrade)
results.Rspamd.DeliverabilityScore = api.PtrTo(rspamdScore)
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
}
report.Rspamd = results.Rspamd
// Add raw headers
if results.Email != nil && results.Email.RawHeaders != "" {
report.RawHeaders = &results.Email.RawHeaders
}
// Calculate overall score as mean of all category scores
categoryScores := []int{
report.Summary.DnsScore,
report.Summary.AuthenticationScore,
report.Summary.BlacklistScore,
report.Summary.ContentScore,
report.Summary.HeaderScore,
report.Summary.SpamScore,
}
var totalScore int
var categoryCount int
for _, score := range categoryScores {
totalScore += score
categoryCount++
}
if categoryCount > 0 {
report.Score = totalScore / categoryCount
} else {
report.Score = 0
}
report.Grade = ScoreToReportGrade(report.Score)
categoryGrades := []string{
string(report.Summary.DnsGrade),
string(report.Summary.AuthenticationGrade),
string(report.Summary.BlacklistGrade),
string(report.Summary.ContentGrade),
string(report.Summary.HeaderGrade),
string(report.Summary.SpamGrade),
}
if report.Score >= 100 {
hasLessThanA := false
for _, grade := range categoryGrades {
if len(grade) < 1 || grade[0] != 'A' {
hasLessThanA = true
}
}
if !hasLessThanA {
report.Grade = "A+"
}
} else {
var minusGrade byte = 0
for _, grade := range categoryGrades {
if len(grade) == 0 {
minusGrade = 255
break
} else if grade[0]-'A' > minusGrade {
minusGrade = grade[0] - 'A'
}
}
if minusGrade < 255 {
report.Grade = api.ReportGrade(string([]byte{'A' + minusGrade}))
}
}
return report
}
// 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
}