Embed rspamd-symbols.json in the binary to provide human-readable descriptions for rspamd symbols in reports. Optionally fetch fresh symbols from a configurable rspamd API URL (--rspamd-api-url flag), falling back to the embedded list on error. Update the frontend to display descriptions alongside symbol names and scores.
308 lines
9.2 KiB
Go
308 lines
9.2 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(
|
|
receiverHostname string,
|
|
dnsTimeout time.Duration,
|
|
httpTimeout time.Duration,
|
|
rbls []string,
|
|
dnswls []string,
|
|
checkAllIPs bool,
|
|
rspamdAPIURL string,
|
|
) *ReportGenerator {
|
|
return &ReportGenerator{
|
|
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
|
|
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
|
rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
|
|
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.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
|
|
}
|