Fix spamassassin report details

This commit is contained in:
nemunaire 2025-10-20 09:27:42 +07:00
commit 1fa7af4c2b
2 changed files with 185 additions and 9 deletions

View file

@ -86,7 +86,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss
// Parse X-Spam-Report header for detailed test results
if reportHeader, ok := headers["X-Spam-Report"]; ok {
result.RawReport = reportHeader
result.RawReport = strings.Replace(reportHeader, " * ", "\n * ", -1)
a.parseSpamReport(reportHeader, result)
}
@ -140,20 +140,25 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass
// Format varies, but typically:
// * 1.5 TEST_NAME Description of test
// * 0.0 TEST_NAME2 Description
// Note: mail.Header.Get() joins continuation lines, so newlines are removed.
// We split on '*' to separate individual tests.
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
// Split by lines
lines := strings.Split(report, "\n")
// The report header has been joined by mail.Header.Get(), so we split on '*'
// Each segment starting with '*' is either a test line or continuation
segments := strings.Split(report, "*")
// Regex to match test lines: * score TEST_NAME Description
testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
// Regex to match test lines: score TEST_NAME Description
// Format: " 0.0 TEST_NAME Description" or " -0.1 TEST_NAME Description"
testRe := regexp.MustCompile(`^\s*(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
continue
}
matches := testRe.FindStringSubmatch(line)
// Try to match as a test line
matches := testRe.FindStringSubmatch(segment)
if len(matches) > 3 {
testName := matches[2]
score, _ := strconv.ParseFloat(matches[1], 64)

View file

@ -22,6 +22,7 @@
package analyzer
import (
"bytes"
"net/mail"
"strings"
"testing"
@ -480,6 +481,176 @@ func TestGenerateTestCheck(t *testing.T) {
}
}
const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec
X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED,
RCVD_IN_VALIDITY_RPBL_BLOCKED,RCVD_IN_VALIDITY_SAFE_BLOCKED,
SPF_HELO_NONE,SPF_PASS autolearn=disabled version=4.0.1
X-Spam-Level:
X-Spam-Report:
* 0.0 RCVD_IN_VALIDITY_SAFE_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
* to Validity was blocked. See
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
* more information.
* [80.67.179.207 listed in sa-accredit.habeas.com]
* 0.0 RCVD_IN_VALIDITY_RPBL_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
* to Validity was blocked. See
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
* more information.
* [80.67.179.207 listed in bl.score.senderscore.com]
* 0.0 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED RBL: ADMINISTRATOR NOTICE: The
* query to Validity was blocked. See
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
* more information.
* [80.67.179.207 listed in sa-trusted.bondedsender.org]
* -0.0 SPF_PASS SPF: sender matches SPF record
* 0.0 SPF_HELO_NONE SPF: HELO does not publish an SPF Record
* -0.1 DKIM_VALID Message has at least one valid DKIM or DK signature
* 0.1 DKIM_SIGNED Message has a DKIM or DK signature, not necessarily
* valid
* -0.1 DKIM_VALID_AU Message has a valid DKIM or DK signature from author's
* domain
Date: Sun, 19 Oct 2025 08:37:30 +0000
Message-ID: <aPSjR57mUnCAt7sp@happydomain.org>
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
BODY`
// TestAnalyzeRealEmailExample tests the analyzer with the real example email file
func TestAnalyzeRealEmailExample(t *testing.T) {
// Parse the email using the standard net/mail package
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithSpamassassinHeader))
if err != nil {
t.Fatalf("Failed to parse email: %v", err)
}
// Create analyzer and analyze SpamAssassin headers
analyzer := NewSpamAssassinAnalyzer()
result := analyzer.AnalyzeSpamAssassin(email)
// Validate that we got a result
if result == nil {
t.Fatal("Expected SpamAssassin result, got nil")
}
// Validate IsSpam flag (should be false for this email)
if result.IsSpam {
t.Error("IsSpam should be false for real_example.eml")
}
// Validate score (should be -0.1)
expectedScore := -0.1
if result.Score != expectedScore {
t.Errorf("Score = %v, want %v", result.Score, expectedScore)
}
// Validate required score (should be 5.0)
expectedRequired := 5.0
if result.RequiredScore != expectedRequired {
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired)
}
// Validate version
if !strings.Contains(result.Version, "SpamAssassin") {
t.Errorf("Version should contain 'SpamAssassin', got: %s", result.Version)
}
// Validate that tests were extracted
if len(result.Tests) == 0 {
t.Error("Expected tests to be extracted, got none")
}
// Check for expected tests from the real email
expectedTests := map[string]bool{
"DKIM_SIGNED": true,
"DKIM_VALID": true,
"DKIM_VALID_AU": true,
"SPF_PASS": true,
"SPF_HELO_NONE": true,
}
for _, testName := range result.Tests {
if expectedTests[testName] {
t.Logf("Found expected test: %s", testName)
}
}
// Validate that test details were parsed from X-Spam-Report
if len(result.TestDetails) == 0 {
t.Error("Expected test details to be parsed from X-Spam-Report, got none")
}
// Log what we actually got for debugging
t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails))
for name, detail := range result.TestDetails {
t.Logf(" %s: score=%v, description=%s", name, detail.Score, detail.Description)
}
// Define expected test details with their scores
expectedTestDetails := map[string]float64{
"SPF_PASS": -0.0,
"SPF_HELO_NONE": 0.0,
"DKIM_VALID": -0.1,
"DKIM_SIGNED": 0.1,
"DKIM_VALID_AU": -0.1,
"RCVD_IN_VALIDITY_SAFE_BLOCKED": 0.0,
"RCVD_IN_VALIDITY_RPBL_BLOCKED": 0.0,
"RCVD_IN_VALIDITY_CERTIFIED_BLOCKED": 0.0,
}
// Iterate over expected tests and verify they exist in TestDetails
for testName, expectedScore := range expectedTestDetails {
detail, ok := result.TestDetails[testName]
if !ok {
t.Errorf("Expected test %s not found in TestDetails", testName)
continue
}
if detail.Score != expectedScore {
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore)
}
if detail.Description == "" {
t.Errorf("Test %s should have a description", testName)
}
}
// Test GetSpamAssassinScore
score := analyzer.GetSpamAssassinScore(result)
if score != 2.0 {
t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score)
}
// Test GenerateSpamAssassinChecks
checks := analyzer.GenerateSpamAssassinChecks(result)
if len(checks) < 1 {
t.Fatal("Expected at least 1 check, got none")
}
// Main check should be PASS with excellent score
mainCheck := checks[0]
if mainCheck.Status != api.CheckStatusPass {
t.Errorf("Main check status = %v, want %v", mainCheck.Status, api.CheckStatusPass)
}
if mainCheck.Category != api.Spam {
t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
}
if !strings.Contains(mainCheck.Message, "spam score") {
t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message)
}
if mainCheck.Score != 2.0 {
t.Errorf("Main check score = %v, want 2.0", mainCheck.Score)
}
// Log all checks for debugging
t.Logf("Generated %d checks:", len(checks))
for i, check := range checks {
t.Logf(" Check %d: %s - %s (score: %.1f, status: %s)",
i+1, check.Name, check.Message, check.Score, check.Status)
}
}
// Helper function to compare string slices
func stringSliceEqual(a, b []string) bool {
if len(a) != len(b) {