Add rspamd as a second spam filter alongside SpamAssassin
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Closes: #36
This commit is contained in:
parent
8fda7746a1
commit
51321ecb1a
19 changed files with 513 additions and 28 deletions
|
|
@ -264,6 +264,26 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string {
|
|||
return headers
|
||||
}
|
||||
|
||||
// GetRspamdHeaders extracts rspamd-related headers
|
||||
func (e *EmailMessage) GetRspamdHeaders() map[string]string {
|
||||
headers := make(map[string]string)
|
||||
|
||||
rspamdHeaders := []string{
|
||||
"X-Spamd-Result",
|
||||
"X-Rspamd-Score",
|
||||
"X-Rspamd-Action",
|
||||
"X-Rspamd-Server",
|
||||
}
|
||||
|
||||
for _, headerName := range rspamdHeaders {
|
||||
if value := e.Header.Get(headerName); value != "" {
|
||||
headers[headerName] = value
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// GetTextParts returns all text/plain parts
|
||||
func (e *EmailMessage) GetTextParts() []MessagePart {
|
||||
return filterParts(e.Parts, func(p MessagePart) bool {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import (
|
|||
type ReportGenerator struct {
|
||||
authAnalyzer *AuthenticationAnalyzer
|
||||
spamAnalyzer *SpamAssassinAnalyzer
|
||||
rspamdAnalyzer *RspamdAnalyzer
|
||||
dnsAnalyzer *DNSAnalyzer
|
||||
rblChecker *RBLChecker
|
||||
contentAnalyzer *ContentAnalyzer
|
||||
|
|
@ -49,6 +50,7 @@ func NewReportGenerator(
|
|||
return &ReportGenerator{
|
||||
authAnalyzer: NewAuthenticationAnalyzer(),
|
||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||
rspamdAnalyzer: NewRspamdAnalyzer(),
|
||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
||||
contentAnalyzer: NewContentAnalyzer(httpTimeout),
|
||||
|
|
@ -65,6 +67,7 @@ type AnalysisResults struct {
|
|||
Headers *api.HeaderAnalysis
|
||||
RBL *RBLResults
|
||||
SpamAssassin *api.SpamAssassinResult
|
||||
Rspamd *api.RspamdResult
|
||||
}
|
||||
|
||||
// AnalyzeEmail performs complete email analysis
|
||||
|
|
@ -79,6 +82,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
|||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
|
||||
results.RBL = r.rblChecker.CheckEmail(email)
|
||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
|
||||
results.Content = r.contentAnalyzer.AnalyzeContent(email)
|
||||
|
||||
return results
|
||||
|
|
@ -134,10 +138,26 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
|||
blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL)
|
||||
}
|
||||
|
||||
spamScore := 0
|
||||
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
|
||||
if results.SpamAssassin != nil {
|
||||
spamScore, spamGrade = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
||||
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{
|
||||
|
|
@ -177,9 +197,22 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
|||
report.Blacklists = &results.RBL.Checks
|
||||
}
|
||||
|
||||
// Add SpamAssassin result
|
||||
// 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
|
||||
|
|
|
|||
152
pkg/analyzer/rspamd.go
Normal file
152
pkg/analyzer/rspamd.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2026 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 (
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// Default rspamd action thresholds (rspamd built-in defaults)
|
||||
const (
|
||||
rspamdDefaultRejectThreshold float32 = 15
|
||||
rspamdDefaultAddHeaderThreshold float32 = 6
|
||||
)
|
||||
|
||||
// RspamdAnalyzer analyzes rspamd results from email headers
|
||||
type RspamdAnalyzer struct{}
|
||||
|
||||
// NewRspamdAnalyzer creates a new rspamd analyzer
|
||||
func NewRspamdAnalyzer() *RspamdAnalyzer {
|
||||
return &RspamdAnalyzer{}
|
||||
}
|
||||
|
||||
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
||||
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||
headers := email.GetRspamdHeaders()
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.RspamdResult{
|
||||
Symbols: make(map[string]api.RspamdSymbol),
|
||||
}
|
||||
|
||||
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
|
||||
a.parseSpamdResult(spamdResult, result)
|
||||
}
|
||||
|
||||
// Parse X-Rspamd-Score as override/fallback for score
|
||||
if scoreHeader, ok := headers["X-Rspamd-Score"]; ok {
|
||||
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
|
||||
result.Score = float32(score)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse X-Rspamd-Server
|
||||
if serverHeader, ok := headers["X-Rspamd-Server"]; ok {
|
||||
server := strings.TrimSpace(serverHeader)
|
||||
result.Server = &server
|
||||
}
|
||||
|
||||
// Derive IsSpam from score vs reject threshold.
|
||||
if result.Threshold > 0 {
|
||||
result.IsSpam = result.Score >= result.Threshold
|
||||
} else {
|
||||
result.IsSpam = result.Score >= rspamdDefaultAddHeaderThreshold
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseSpamdResult parses the X-Spamd-Result header
|
||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResult) {
|
||||
// Extract score and threshold from the first line
|
||||
// e.g. "default: False [-3.91 / 15.00]"
|
||||
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
|
||||
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 2 {
|
||||
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
|
||||
result.Score = float32(score)
|
||||
}
|
||||
if threshold, err := strconv.ParseFloat(matches[2], 64); err == nil {
|
||||
result.Threshold = float32(threshold)
|
||||
|
||||
// No threshold? use default AddHeaderThreshold
|
||||
if result.Threshold <= 0 {
|
||||
result.Threshold = rspamdDefaultAddHeaderThreshold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse is_spam from header (before we may get action from X-Rspamd-Action)
|
||||
firstLine := strings.SplitN(header, ";", 2)[0]
|
||||
if strings.Contains(firstLine, ": True") || strings.Contains(firstLine, ": true") {
|
||||
result.IsSpam = true
|
||||
}
|
||||
|
||||
// Parse symbols: SYMBOL(score)[params]
|
||||
// Each symbol entry is separated by ";"
|
||||
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[([^\]]*)\])?`)
|
||||
for _, part := range strings.Split(header, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
matches := symbolRe.FindStringSubmatch(part)
|
||||
if len(matches) > 2 {
|
||||
name := matches[1]
|
||||
score, _ := strconv.ParseFloat(matches[2], 64)
|
||||
sym := api.RspamdSymbol{
|
||||
Name: name,
|
||||
Score: float32(score),
|
||||
}
|
||||
if len(matches) > 3 && matches[3] != "" {
|
||||
params := matches[3]
|
||||
sym.Params = ¶ms
|
||||
}
|
||||
result.Symbols[name] = sym
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
|
||||
func (a *RspamdAnalyzer) CalculateRspamdScore(result *api.RspamdResult) (int, string) {
|
||||
if result == nil {
|
||||
return 100, "" // rspamd not installed
|
||||
}
|
||||
|
||||
threshold := result.Threshold
|
||||
percentage := 100 - int(math.Round(float64(result.Score*100/(2*threshold))))
|
||||
|
||||
if percentage > 100 {
|
||||
return 100, "A+"
|
||||
} else if percentage < 0 {
|
||||
return 0, "F"
|
||||
}
|
||||
|
||||
// Linear scale between 0 and threshold
|
||||
return percentage, ScoreToGrade(percentage)
|
||||
}
|
||||
|
|
@ -69,3 +69,31 @@ func ScoreToGradeKind(score int) string {
|
|||
func ScoreToReportGrade(score int) api.ReportGrade {
|
||||
return api.ReportGrade(ScoreToGrade(score))
|
||||
}
|
||||
|
||||
// gradeRank returns a numeric rank for a grade (lower = worse)
|
||||
func gradeRank(grade string) int {
|
||||
switch grade {
|
||||
case "A+":
|
||||
return 6
|
||||
case "A":
|
||||
return 5
|
||||
case "B":
|
||||
return 4
|
||||
case "C":
|
||||
return 3
|
||||
case "D":
|
||||
return 2
|
||||
case "E":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MinGrade returns the minimal (worse) grade between the two given grades
|
||||
func MinGrade(a, b string) string {
|
||||
if gradeRank(a) <= gradeRank(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
|
|||
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
|
||||
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) (int, string) {
|
||||
if result == nil {
|
||||
return 100, "" // No spam scan results, assume good
|
||||
return 100, "" // No spam scan results
|
||||
}
|
||||
|
||||
// SpamAssassin score typically ranges from -10 to +20
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue