Remove checks

This commit is contained in:
nemunaire 2025-10-21 15:27:43 +07:00
commit eef6f4cf57
28 changed files with 1635 additions and 3824 deletions

View file

@ -240,7 +240,6 @@ components:
- test_id
- score
- grade
- checks
- created_at
properties:
id:
@ -264,10 +263,6 @@ components:
example: "A"
summary:
$ref: '#/components/schemas/ScoreSummary'
checks:
type: array
items:
$ref: '#/components/schemas/Check'
authentication:
$ref: '#/components/schemas/AuthenticationResults'
spamassassin:
@ -289,6 +284,10 @@ components:
listed: false
- rbl: "bl.spamcop.net"
listed: false
content_analysis:
$ref: '#/components/schemas/ContentAnalysis'
header_analysis:
$ref: '#/components/schemas/HeaderAnalysis'
raw_headers:
type: string
description: Raw email headers
@ -336,55 +335,272 @@ components:
description: Header quality score (in percentage)
example: 9
Check:
ContentAnalysis:
type: object
properties:
has_html:
type: boolean
description: Whether email contains HTML part
example: true
has_plaintext:
type: boolean
description: Whether email contains plaintext part
example: true
html_issues:
type: array
items:
$ref: '#/components/schemas/ContentIssue'
description: Issues found in HTML content
links:
type: array
items:
$ref: '#/components/schemas/LinkCheck'
description: Analysis of links found in the email
images:
type: array
items:
$ref: '#/components/schemas/ImageCheck'
description: Analysis of images in the email
text_to_image_ratio:
type: number
format: float
description: Ratio of text to images (higher is better)
example: 0.75
has_unsubscribe_link:
type: boolean
description: Whether email contains an unsubscribe link
example: true
unsubscribe_methods:
type: array
items:
type: string
enum: [link, mailto, list-unsubscribe-header, one-click]
description: Available unsubscribe methods
example: ["link", "list-unsubscribe-header"]
ContentIssue:
type: object
required:
- category
- name
- status
- score
- grade
- type
- severity
- message
properties:
category:
type:
type: string
enum: [authentication, dns, content, blacklist, headers, spam]
description: Check category
example: "authentication"
name:
type: string
description: Check name
example: "DKIM Signature"
status:
type: string
enum: [pass, fail, warn, info, error]
description: Check result status
example: "pass"
score:
type: integer
description: Points contributed to total score
example: 10
grade:
type: string
enum: [A+, A, B, C, D, E, F]
description: Letter grade representation of the score (A+ is best, F is worst)
example: "A"
message:
type: string
description: Human-readable result message
example: "DKIM signature is valid"
details:
type: string
description: Additional details (may be JSON)
enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link]
description: Type of content issue
example: "missing_alt"
severity:
type: string
enum: [critical, high, medium, low, info]
description: Issue severity
example: "info"
example: "medium"
message:
type: string
description: Human-readable description
example: "3 images are missing alt attributes"
location:
type: string
description: Where the issue was found
example: "HTML body line 42"
advice:
type: string
description: Remediation advice
example: "Your DKIM configuration is correct"
description: How to fix this issue
example: "Add descriptive alt text to all images for better accessibility and deliverability"
LinkCheck:
type: object
required:
- url
- status
properties:
url:
type: string
format: uri
description: The URL found in the email
example: "https://example.com/page"
status:
type: string
enum: [valid, broken, suspicious, redirected, timeout]
description: Link validation status
example: "valid"
http_code:
type: integer
description: HTTP status code received
example: 200
redirect_chain:
type: array
items:
type: string
description: URLs in the redirect chain, if any
example: ["https://example.com", "https://www.example.com"]
is_shortened:
type: boolean
description: Whether this is a URL shortener
example: false
ImageCheck:
type: object
required:
- has_alt
properties:
src:
type: string
description: Image source URL or path
example: "https://example.com/logo.png"
has_alt:
type: boolean
description: Whether image has alt attribute
example: true
alt_text:
type: string
description: Alt text content
example: "Company Logo"
is_tracking_pixel:
type: boolean
description: Whether this appears to be a tracking pixel (1x1 image)
example: false
HeaderAnalysis:
type: object
properties:
has_mime_structure:
type: boolean
description: Whether body has a MIME structure
example: true
headers:
type: object
additionalProperties:
$ref: '#/components/schemas/HeaderCheck'
description: Map of header names to their check results (e.g., "from", "to", "dkim-signature")
example:
from:
present: true
value: "sender@example.com"
valid: true
importance: "required"
date:
present: true
value: "Mon, 1 Jan 2024 12:00:00 +0000"
valid: true
importance: "required"
received_chain:
type: array
items:
$ref: '#/components/schemas/ReceivedHop'
description: Chain of Received headers showing email path
domain_alignment:
$ref: '#/components/schemas/DomainAlignment'
issues:
type: array
items:
$ref: '#/components/schemas/HeaderIssue'
description: Issues found in headers
HeaderCheck:
type: object
required:
- present
properties:
present:
type: boolean
description: Whether the header is present
example: true
value:
type: string
description: Header value
example: "sender@example.com"
valid:
type: boolean
description: Whether the value is valid/well-formed
example: true
importance:
type: string
enum: [required, recommended, optional]
description: How important this header is for deliverability
example: "required"
issues:
type: array
items:
type: string
description: Any issues with this header
example: ["Invalid date format"]
ReceivedHop:
type: object
properties:
from:
type: string
description: Sending server hostname
example: "mail.example.com"
by:
type: string
description: Receiving server hostname
example: "mx.receiver.com"
with:
type: string
description: Protocol used
example: "ESMTPS"
id:
type: string
description: Message ID at this hop
timestamp:
type: string
format: date-time
description: When this hop occurred
DomainAlignment:
type: object
properties:
from_domain:
type: string
description: Domain from From header
example: "example.com"
return_path_domain:
type: string
description: Domain from Return-Path header
example: "example.com"
dkim_domains:
type: array
items:
type: string
description: Domains from DKIM signatures
example: ["example.com"]
aligned:
type: boolean
description: Whether all domains align
example: true
issues:
type: array
items:
type: string
description: Alignment issues
example: ["Return-Path domain does not match From domain"]
HeaderIssue:
type: object
required:
- header
- severity
- message
properties:
header:
type: string
description: Header name
example: "Date"
severity:
type: string
enum: [critical, high, medium, low, info]
description: Issue severity
example: "medium"
message:
type: string
description: Human-readable description
example: "Date header is in the future"
advice:
type: string
description: How to fix this issue
example: "Ensure your mail server clock is synchronized with NTP"
AuthenticationResults:
type: object
@ -522,6 +738,9 @@ components:
type: string
description: RBL response code or message
example: "127.0.0.2"
error:
type: string
description: RBL error if any
Status:
type: object

View file

@ -31,7 +31,6 @@ import (
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/pkg/analyzer"
)
@ -97,42 +96,7 @@ func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyze
fmt.Fprintln(writer, "DETAILED CHECK RESULTS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
// Group checks by category
categories := make(map[api.CheckCategory][]api.Check)
for _, check := range result.Report.Checks {
categories[check.Category] = append(categories[check.Category], check)
}
// Print checks by category
categoryOrder := []api.CheckCategory{
api.Authentication,
api.Dns,
api.Blacklist,
api.Content,
api.Headers,
}
for _, category := range categoryOrder {
checks, ok := categories[category]
if !ok || len(checks) == 0 {
continue
}
fmt.Fprintf(writer, "\n%s:\n", category)
for _, check := range checks {
statusSymbol := "✓"
if check.Status == api.CheckStatusFail {
statusSymbol = "✗"
} else if check.Status == api.CheckStatusWarn {
statusSymbol = "⚠"
}
fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message)
if check.Advice != nil && *check.Advice != "" {
fmt.Fprintf(writer, " → %s\n", *check.Advice)
}
}
}
// TODO
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
return nil

View file

@ -191,7 +191,7 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
result.Selector = &selector
}
result.Details = &part
result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim="))
return result
}
@ -215,7 +215,7 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
result.Domain = &domain
}
result.Details = &part
result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc="))
return result
}
@ -246,7 +246,7 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
result.Selector = &selector
}
result.Details = &part
result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi="))
return result
}
@ -263,7 +263,7 @@ func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
result.Result = api.ARCResultResult(resultStr)
}
result.Details = &part
result.Details = api.PtrTo(strings.TrimPrefix(part, "arc="))
return result
}
@ -467,3 +467,75 @@ func textprotoCanonical(s string) string {
}
return strings.Join(words, "-")
}
// CalculateAuthenticationScore calculates the authentication score from auth results
// Returns a score from 0-100 where higher is better
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) int {
if results == nil {
return 0
}
score := 0
// SPF (30 points)
if results.Spf != nil {
switch results.Spf.Result {
case api.AuthResultResultPass:
score += 30
case api.AuthResultResultNeutral, api.AuthResultResultNone:
score += 15
case api.AuthResultResultSoftfail:
score += 5
default: // fail, temperror, permerror
score += 0
}
}
// DKIM (30 points) - at least one passing signature
if results.Dkim != nil && len(*results.Dkim) > 0 {
hasPass := false
for _, dkim := range *results.Dkim {
if dkim.Result == api.AuthResultResultPass {
hasPass = true
break
}
}
if hasPass {
score += 30
} else {
// Has DKIM signatures but none passed
score += 10
}
}
// DMARC (30 points)
if results.Dmarc != nil {
switch results.Dmarc.Result {
case api.AuthResultResultPass:
score += 30
case api.AuthResultResultNone:
score += 10
default: // fail
score += 0
}
}
// BIMI (10 points)
if results.Bimi != nil {
switch results.Bimi.Result {
case api.AuthResultResultPass:
score += 10
case api.AuthResultResultNone:
score += 5
default: // fail
score += 0
}
}
// Ensure score doesn't exceed 100
if score > 100 {
score = 100
}
return score
}

View file

@ -1,317 +0,0 @@
// 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 (
"fmt"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// GenerateAuthenticationChecks generates check results for authentication
func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check {
var checks []api.Check
// SPF check
if results.Spf != nil {
check := a.generateSPFCheck(results.Spf)
checks = append(checks, check)
} else {
checks = append(checks, api.Check{
Category: api.Authentication,
Name: "SPF Record",
Status: api.CheckStatusWarn,
Score: 0,
Message: "No SPF authentication result found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"),
})
}
// DKIM check
if results.Dkim != nil && len(*results.Dkim) > 0 {
for i, dkim := range *results.Dkim {
check := a.generateDKIMCheck(&dkim, i)
checks = append(checks, check)
}
} else {
checks = append(checks, api.Check{
Category: api.Authentication,
Name: "DKIM Signature",
Status: api.CheckStatusWarn,
Score: 0,
Message: "No DKIM signature found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"),
})
}
// DMARC check
if results.Dmarc != nil {
check := a.generateDMARCCheck(results.Dmarc)
checks = append(checks, check)
} else {
checks = append(checks, api.Check{
Category: api.Authentication,
Name: "DMARC Policy",
Status: api.CheckStatusWarn,
Score: 0,
Message: "No DMARC authentication result found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Implement DMARC policy for your domain"),
})
}
// BIMI check (optional, informational only)
if results.Bimi != nil {
check := a.generateBIMICheck(results.Bimi)
checks = append(checks, check)
}
// ARC check (optional, for forwarded emails)
if results.Arc != nil {
check := a.generateARCCheck(results.Arc)
checks = append(checks, check)
}
return checks
}
func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check {
check := api.Check{
Category: api.Authentication,
Name: "SPF Record",
}
switch spf.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 100
check.Message = "SPF validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0
check.Message = "SPF validation failed"
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
case api.AuthResultResultSoftfail:
check.Status = api.CheckStatusWarn
check.Score = 50
check.Message = "SPF validation softfail"
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Review your SPF record configuration")
case api.AuthResultResultNeutral:
check.Status = api.CheckStatusWarn
check.Score = 50
check.Message = "SPF validation neutral"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("Consider tightening your SPF policy")
default:
check.Status = api.CheckStatusWarn
check.Score = 0
check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Review your SPF record configuration")
}
if spf.Details != nil {
check.Details = spf.Details
} else if spf.Domain != nil {
details := fmt.Sprintf("Domain: %s", *spf.Domain)
check.Details = &details
}
return check
}
func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check {
check := api.Check{
Category: api.Authentication,
Name: fmt.Sprintf("DKIM Signature #%d", index+1),
}
switch dkim.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 10
check.Message = "DKIM signature is valid"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your DKIM signature is properly configured")
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0
check.Message = "DKIM signature validation failed"
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
default:
check.Status = api.CheckStatusWarn
check.Score = 0
check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
}
if dkim.Details != nil {
check.Details = dkim.Details
} else {
var detailsParts []string
if dkim.Domain != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
}
if dkim.Selector != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, ", ")
check.Details = &details
}
}
return check
}
func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check {
check := api.Check{
Category: api.Authentication,
Name: "DMARC Policy",
}
switch dmarc.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 10
check.Message = "DMARC validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0
check.Message = "DMARC validation failed"
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
default:
check.Status = api.CheckStatusWarn
check.Score = 0
check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Configure DMARC policy for your domain")
}
if dmarc.Details != nil {
check.Details = dmarc.Details
} else if dmarc.Domain != nil {
details := fmt.Sprintf("Domain: %s", *dmarc.Domain)
check.Details = &details
}
return check
}
func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check {
check := api.Check{
Category: api.Authentication,
Name: "BIMI (Brand Indicators)",
}
switch bimi.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 0 // BIMI doesn't contribute to score (branding feature)
check.Message = "BIMI validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI")
case api.AuthResultResultFail:
check.Status = api.CheckStatusInfo
check.Score = 0
check.Message = "BIMI validation failed"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record")
default:
check.Status = api.CheckStatusInfo
check.Score = 0
check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients")
}
if bimi.Details != nil {
check.Details = bimi.Details
} else if bimi.Domain != nil {
details := fmt.Sprintf("Domain: %s", *bimi.Domain)
check.Details = &details
}
return check
}
func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check {
check := api.Check{
Category: api.Authentication,
Name: "ARC (Authenticated Received Chain)",
}
switch arc.Result {
case api.ARCResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 0 // ARC doesn't contribute to score (informational for forwarding)
check.Message = "ARC chain validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication")
case api.ARCResultResultFail:
check.Status = api.CheckStatusWarn
check.Score = 0
check.Message = "ARC chain validation failed"
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries")
default:
check.Status = api.CheckStatusInfo
check.Score = 0
check.Message = "No ARC chain present"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries")
}
if arc.Details != nil {
check.Details = arc.Details
} else {
// Build details
var detailsParts []string
if arc.ChainLength != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength))
}
if arc.ChainValid != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid))
}
if arc.Details != nil {
detailsParts = append(detailsParts, *arc.Details)
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, ", ")
check.Details = &details
}
}
return check
}

View file

@ -22,7 +22,6 @@
package analyzer
import (
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
@ -246,250 +245,6 @@ func TestParseBIMIResult(t *testing.T) {
}
}
func TestGenerateAuthSPFCheck(t *testing.T) {
tests := []struct {
name string
spf *api.AuthResult
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "SPF pass",
spf: &api.AuthResult{
Result: api.AuthResultResultPass,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 10,
},
{
name: "SPF fail",
spf: &api.AuthResult{
Result: api.AuthResultResultFail,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
{
name: "SPF softfail",
spf: &api.AuthResult{
Result: api.AuthResultResultSoftfail,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 5,
},
{
name: "SPF neutral",
spf: &api.AuthResult{
Result: api.AuthResultResultNeutral,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 5,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateSPFCheck(tt.spf)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Authentication {
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
}
if check.Name != "SPF Record" {
t.Errorf("Name = %q, want %q", check.Name, "SPF Record")
}
})
}
}
func TestGenerateAuthDKIMCheck(t *testing.T) {
tests := []struct {
name string
dkim *api.AuthResult
index int
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "DKIM pass",
dkim: &api.AuthResult{
Result: api.AuthResultResultPass,
Domain: api.PtrTo("example.com"),
Selector: api.PtrTo("default"),
},
index: 0,
expectedStatus: api.CheckStatusPass,
expectedScore: 10,
},
{
name: "DKIM fail",
dkim: &api.AuthResult{
Result: api.AuthResultResultFail,
Domain: api.PtrTo("example.com"),
Selector: api.PtrTo("default"),
},
index: 0,
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
{
name: "DKIM none",
dkim: &api.AuthResult{
Result: api.AuthResultResultNone,
Domain: api.PtrTo("example.com"),
Selector: api.PtrTo("default"),
},
index: 0,
expectedStatus: api.CheckStatusWarn,
expectedScore: 0,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateDKIMCheck(tt.dkim, tt.index)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Authentication {
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
}
if !strings.Contains(check.Name, "DKIM Signature") {
t.Errorf("Name should contain 'DKIM Signature', got %q", check.Name)
}
})
}
}
func TestGenerateAuthDMARCCheck(t *testing.T) {
tests := []struct {
name string
dmarc *api.AuthResult
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "DMARC pass",
dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 10,
},
{
name: "DMARC fail",
dmarc: &api.AuthResult{
Result: api.AuthResultResultFail,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateDMARCCheck(tt.dmarc)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Authentication {
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
}
if check.Name != "DMARC Policy" {
t.Errorf("Name = %q, want %q", check.Name, "DMARC Policy")
}
})
}
}
func TestGenerateAuthBIMICheck(t *testing.T) {
tests := []struct {
name string
bimi *api.AuthResult
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "BIMI pass",
bimi: &api.AuthResult{
Result: api.AuthResultResultPass,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0, // BIMI doesn't contribute to score
},
{
name: "BIMI fail",
bimi: &api.AuthResult{
Result: api.AuthResultResultFail,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0,
},
{
name: "BIMI none",
bimi: &api.AuthResult{
Result: api.AuthResultResultNone,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateBIMICheck(tt.bimi)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Authentication {
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
}
if check.Name != "BIMI (Brand Indicators)" {
t.Errorf("Name = %q, want %q", check.Name, "BIMI (Brand Indicators)")
}
// BIMI should always have score of 0.0 (branding feature)
if check.Score != 0.0 {
t.Error("BIMI should not contribute to deliverability score")
}
})
}
}
func TestGetAuthenticationScore(t *testing.T) {
tests := []struct {
name string
@ -563,11 +318,11 @@ func TestGetAuthenticationScore(t *testing.T) {
},
}
scorer := NewDeliverabilityScorer()
scorer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := scorer.GetAuthenticationScore(tt.results)
score := scorer.CalculateAuthenticationScore(tt.results)
if score != tt.expectedScore {
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
@ -576,92 +331,6 @@ func TestGetAuthenticationScore(t *testing.T) {
}
}
func TestGenerateAuthenticationChecks(t *testing.T) {
tests := []struct {
name string
results *api.AuthenticationResults
expectedChecks int
}{
{
name: "All authentication methods present",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Bimi: &api.AuthResult{
Result: api.AuthResultResultPass,
},
},
expectedChecks: 4, // SPF, DKIM, DMARC, BIMI
},
{
name: "Without BIMI",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
},
},
expectedChecks: 3, // SPF, DKIM, DMARC
},
{
name: "No authentication results",
results: &api.AuthenticationResults{},
expectedChecks: 3, // SPF, DKIM, DMARC warnings for missing
},
{
name: "With ARC",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Arc: &api.ARCResult{
Result: api.ARCResultResultPass,
ChainLength: api.PtrTo(2),
ChainValid: api.PtrTo(true),
},
},
expectedChecks: 4, // SPF, DKIM, DMARC, ARC
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateAuthenticationChecks(tt.results)
if len(checks) != tt.expectedChecks {
t.Errorf("Got %d checks, want %d", len(checks), tt.expectedChecks)
}
// Verify all checks have the Authentication category
for _, check := range checks {
if check.Category != api.Authentication {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Authentication)
}
}
})
}
}
func TestParseARCResult(t *testing.T) {
tests := []struct {
name string
@ -783,64 +452,3 @@ func TestValidateARCChain(t *testing.T) {
})
}
}
func TestGenerateARCCheck(t *testing.T) {
tests := []struct {
name string
arc *api.ARCResult
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "ARC pass",
arc: &api.ARCResult{
Result: api.ARCResultResultPass,
ChainLength: api.PtrTo(2),
ChainValid: api.PtrTo(true),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0, // ARC doesn't contribute to score
},
{
name: "ARC fail",
arc: &api.ARCResult{
Result: api.ARCResultResultFail,
ChainLength: api.PtrTo(1),
ChainValid: api.PtrTo(false),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0,
},
{
name: "ARC none",
arc: &api.ARCResult{
Result: api.ARCResultResultNone,
ChainLength: api.PtrTo(0),
ChainValid: api.PtrTo(true),
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateARCCheck(tt.arc)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Authentication {
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
}
if !strings.Contains(check.Name, "ARC") {
t.Errorf("Name should contain 'ARC', got %q", check.Name)
}
})
}
}

View file

@ -459,294 +459,151 @@ func (c *ContentAnalyzer) normalizeText(text string) string {
return text
}
// GenerateContentChecks generates check results for content analysis
func (c *ContentAnalyzer) GenerateContentChecks(results *ContentResults) []api.Check {
var checks []api.Check
// GenerateContentAnalysis creates structured content analysis from results
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis {
if results == nil {
return checks
return nil
}
// HTML validity check
checks = append(checks, c.generateHTMLValidityCheck(results))
// Link checks
checks = append(checks, c.generateLinkChecks(results)...)
// Image checks
checks = append(checks, c.generateImageChecks(results)...)
// Unsubscribe link check
checks = append(checks, c.generateUnsubscribeCheck(results))
// Text/HTML consistency check
if results.TextContent != "" && results.HTMLContent != "" {
checks = append(checks, c.generateTextConsistencyCheck(results))
analysis := &api.ContentAnalysis{
HasHtml: api.PtrTo(results.HTMLContent != ""),
HasPlaintext: api.PtrTo(results.TextContent != ""),
HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
}
// Image-to-text ratio check
// Calculate text-to-image ratio (inverse of image-to-text)
if len(results.Images) > 0 && results.HTMLContent != "" {
checks = append(checks, c.generateImageRatioCheck(results))
}
// Suspicious URLs check
if len(results.SuspiciousURLs) > 0 {
checks = append(checks, c.generateSuspiciousURLCheck(results))
}
return checks
}
// generateHTMLValidityCheck creates a check for HTML validity
func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "HTML Structure",
}
if !results.HTMLValid {
check.Status = api.CheckStatusFail
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "HTML structure is invalid"
if len(results.HTMLErrors) > 0 {
details := strings.Join(results.HTMLErrors, "; ")
check.Details = &details
}
check.Advice = api.PtrTo("Fix HTML structure errors to improve email rendering")
} else {
check.Status = api.CheckStatusPass
check.Score = 2
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "HTML structure is valid"
check.Advice = api.PtrTo("Your HTML is well-formed")
}
return check
}
// generateLinkChecks creates checks for links
func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Check {
var checks []api.Check
if len(results.Links) == 0 {
return checks
}
// Count broken links
brokenLinks := 0
warningLinks := 0
for _, link := range results.Links {
if link.Status >= 400 {
brokenLinks++
} else if link.Warning != "" {
warningLinks++
textLen := float32(len(c.extractTextFromHTML(results.HTMLContent)))
if textLen > 0 {
ratio := textLen / float32(len(results.Images))
analysis.TextToImageRatio = &ratio
}
}
check := api.Check{
Category: api.Content,
Name: "Links",
}
// Build HTML issues
htmlIssues := []api.ContentIssue{}
if brokenLinks > 0 {
check.Status = api.CheckStatusFail
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks)
check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability")
details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks)
check.Details = &details
} else if warningLinks > 0 {
check.Status = api.CheckStatusWarn
check.Score = 3
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks)
check.Advice = api.PtrTo("Review links that could not be verified")
details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 4
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links))
check.Advice = api.PtrTo("Your links are working properly")
}
checks = append(checks, check)
return checks
}
// generateImageChecks creates checks for images
func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Check {
var checks []api.Check
if len(results.Images) == 0 {
return checks
}
// Count images without alt text
noAltCount := 0
for _, img := range results.Images {
if !img.HasAlt {
noAltCount++
// Add HTML parsing errors
if !results.HTMLValid && len(results.HTMLErrors) > 0 {
for _, errMsg := range results.HTMLErrors {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.BrokenHtml,
Severity: api.ContentIssueSeverityHigh,
Message: errMsg,
Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
})
}
}
check := api.Check{
Category: api.Content,
Name: "Image Alt Attributes",
// Add missing alt text issues
if len(results.Images) > 0 {
missingAltCount := 0
for _, img := range results.Images {
if !img.HasAlt {
missingAltCount++
}
}
if missingAltCount > 0 {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.MissingAlt,
Severity: api.ContentIssueSeverityMedium,
Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount),
Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
})
}
}
if noAltCount == len(results.Images) {
check.Status = api.CheckStatusFail
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "No images have alt attributes"
check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability")
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
check.Details = &details
} else if noAltCount > 0 {
check.Status = api.CheckStatusWarn
check.Score = 2
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount)
check.Advice = api.PtrTo("Add alt text to all images for better accessibility")
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All images have alt attributes"
check.Advice = api.PtrTo("Your images are properly tagged for accessibility")
// Add excessive images issue
if results.ImageTextRatio > 10.0 {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.ExcessiveImages,
Severity: api.ContentIssueSeverityMedium,
Message: "Email is excessively image-heavy",
Advice: api.PtrTo("Reduce the number of images relative to text content"),
})
}
checks = append(checks, check)
return checks
// Add suspicious URL issues
for _, suspURL := range results.SuspiciousURLs {
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.SuspiciousLink,
Severity: api.ContentIssueSeverityHigh,
Message: "Suspicious URL detected",
Location: &suspURL,
Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
})
}
if len(htmlIssues) > 0 {
analysis.HtmlIssues = &htmlIssues
}
// Convert links
if len(results.Links) > 0 {
links := make([]api.LinkCheck, 0, len(results.Links))
for _, link := range results.Links {
status := api.Valid
if link.Status >= 400 {
status = api.Broken
} else if !link.IsSafe {
status = api.Suspicious
} else if link.Warning != "" {
status = api.Timeout
}
apiLink := api.LinkCheck{
Url: link.URL,
Status: status,
}
if link.Status > 0 {
apiLink.HttpCode = api.PtrTo(link.Status)
}
// Check if it's a URL shortener
parsedURL, err := url.Parse(link.URL)
if err == nil {
isShortened := c.isSuspiciousURL(link.URL, parsedURL)
apiLink.IsShortened = api.PtrTo(isShortened)
}
links = append(links, apiLink)
}
analysis.Links = &links
}
// Convert images
if len(results.Images) > 0 {
images := make([]api.ImageCheck, 0, len(results.Images))
for _, img := range results.Images {
apiImg := api.ImageCheck{
HasAlt: img.HasAlt,
}
if img.Src != "" {
apiImg.Src = &img.Src
}
if img.AltText != "" {
apiImg.AltText = &img.AltText
}
// Simple heuristic: tracking pixels are typically 1x1
apiImg.IsTrackingPixel = api.PtrTo(false)
images = append(images, apiImg)
}
analysis.Images = &images
}
// Unsubscribe methods
if results.HasUnsubscribe {
methods := []api.ContentAnalysisUnsubscribeMethods{api.Link}
analysis.UnsubscribeMethods = &methods
}
return analysis
}
// generateUnsubscribeCheck creates a check for unsubscribe links
func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Unsubscribe Link",
}
if !results.HasUnsubscribe {
check.Status = api.CheckStatusWarn
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "No unsubscribe link found"
check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)")
} else {
check.Status = api.CheckStatusPass
check.Score = 3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks))
check.Advice = api.PtrTo("Your email includes an unsubscribe option")
}
return check
}
// generateTextConsistencyCheck creates a check for text/HTML consistency
func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Plain Text Consistency",
}
consistency := results.TextPlainRatio
if consistency < 0.3 {
check.Status = api.CheckStatusWarn
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "Plain text and HTML versions differ significantly"
check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content")
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Plain text and HTML versions are consistent"
check.Advice = api.PtrTo("Your multipart email is well-structured")
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
check.Details = &details
}
return check
}
// generateImageRatioCheck creates a check for image-to-text ratio
func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Image-to-Text Ratio",
}
ratio := results.ImageTextRatio
// Flag if more than 1 image per 100 characters (very image-heavy)
if ratio > 10.0 {
check.Status = api.CheckStatusFail
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Email is excessively image-heavy"
check.Advice = api.PtrTo("Reduce the number of images relative to text content")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
check.Details = &details
} else if ratio > 5.0 {
check.Status = api.CheckStatusWarn
check.Score = 2
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "Email has high image-to-text ratio"
check.Advice = api.PtrTo("Consider adding more text content relative to images")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Image-to-text ratio is reasonable"
check.Advice = api.PtrTo("Your content has a good balance of images and text")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
check.Details = &details
}
return check
}
// generateSuspiciousURLCheck creates a check for suspicious URLs
func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Suspicious URLs",
}
count := len(results.SuspiciousURLs)
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count)
check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails")
if count <= 3 {
details := strings.Join(results.SuspiciousURLs, ", ")
check.Details = &details
} else {
details := fmt.Sprintf("%s, and %d more", strings.Join(results.SuspiciousURLs[:3], ", "), count-3)
check.Details = &details
}
return check
}
// GetContentScore calculates the content score (0-20 points)
func (c *ContentAnalyzer) GetContentScore(results *ContentResults) int {
// CalculateContentScore calculates the content score (0-20 points)
func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) int {
if results == nil {
return 0
}

View file

@ -28,7 +28,6 @@ import (
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
"golang.org/x/net/html"
)
@ -608,453 +607,6 @@ func TestAnalyzeContent_ImageAltAttributes(t *testing.T) {
}
}
func TestGenerateHTMLValidityCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "Valid HTML",
results: &ContentResults{
HTMLValid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 2,
},
{
name: "Invalid HTML",
results: &ContentResults{
HTMLValid: false,
HTMLErrors: []string{"Parse error"},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateHTMLValidityCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
})
}
}
func TestGenerateLinkChecks(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "All links valid",
results: &ContentResults{
Links: []LinkCheck{
{URL: "https://example.com", Valid: true, Status: 200},
{URL: "https://example.org", Valid: true, Status: 200},
},
},
expectedStatus: api.CheckStatusPass,
expectedScore: 4,
},
{
name: "Broken links",
results: &ContentResults{
Links: []LinkCheck{
{URL: "https://example.com", Valid: true, Status: 404, Error: "Not found"},
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
{
name: "Links with warnings",
results: &ContentResults{
Links: []LinkCheck{
{URL: "https://example.com", Valid: true, Warning: "Could not verify"},
},
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 3,
},
{
name: "No links",
results: &ContentResults{},
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.generateLinkChecks(tt.results)
if tt.name == "No links" {
if len(checks) != 0 {
t.Errorf("Expected no checks, got %d", len(checks))
}
return
}
if len(checks) == 0 {
t.Fatal("Expected at least one check")
}
check := checks[0]
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
})
}
}
func TestGenerateImageChecks(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "All images have alt",
results: &ContentResults{
Images: []ImageCheck{
{Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
{Src: "img2.jpg", HasAlt: true, AltText: "Alt 2"},
},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No images have alt",
results: &ContentResults{
Images: []ImageCheck{
{Src: "img1.jpg", HasAlt: false},
{Src: "img2.jpg", HasAlt: false},
},
},
expectedStatus: api.CheckStatusFail,
},
{
name: "Some images have alt",
results: &ContentResults{
Images: []ImageCheck{
{Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
{Src: "img2.jpg", HasAlt: false},
},
},
expectedStatus: api.CheckStatusWarn,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.generateImageChecks(tt.results)
if len(checks) == 0 {
t.Fatal("Expected at least one check")
}
check := checks[0]
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
})
}
}
func TestGenerateUnsubscribeCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "Has unsubscribe link",
results: &ContentResults{
HasUnsubscribe: true,
UnsubscribeLinks: []string{"https://example.com/unsubscribe"},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No unsubscribe link",
results: &ContentResults{},
expectedStatus: api.CheckStatusWarn,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateUnsubscribeCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
})
}
}
func TestGenerateTextConsistencyCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "High consistency",
results: &ContentResults{
TextPlainRatio: 0.8,
},
expectedStatus: api.CheckStatusPass,
},
{
name: "Low consistency",
results: &ContentResults{
TextPlainRatio: 0.1,
},
expectedStatus: api.CheckStatusWarn,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateTextConsistencyCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateImageRatioCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "Reasonable ratio",
results: &ContentResults{
ImageTextRatio: 3.0,
Images: []ImageCheck{{}, {}, {}},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "High ratio",
results: &ContentResults{
ImageTextRatio: 7.0,
Images: make([]ImageCheck, 7),
},
expectedStatus: api.CheckStatusWarn,
},
{
name: "Excessive ratio",
results: &ContentResults{
ImageTextRatio: 15.0,
Images: make([]ImageCheck, 15),
},
expectedStatus: api.CheckStatusFail,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateImageRatioCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateSuspiciousURLCheck(t *testing.T) {
results := &ContentResults{
SuspiciousURLs: []string{
"https://bit.ly/abc123",
"https://192.168.1.1/page",
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
check := analyzer.generateSuspiciousURLCheck(results)
if check.Status != api.CheckStatusWarn {
t.Errorf("Status = %v, want %v", check.Status, api.CheckStatusWarn)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
if !strings.Contains(check.Message, "2") {
t.Error("Message should mention the count of suspicious URLs")
}
}
func TestGetContentScore(t *testing.T) {
tests := []struct {
name string
results *ContentResults
minScore int
maxScore int
}{
{
name: "Nil results",
results: nil,
minScore: 0,
maxScore: 0,
},
{
name: "Perfect content",
results: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: true,
TextPlainRatio: 0.8,
ImageTextRatio: 3.0,
},
minScore: 90,
maxScore: 100,
},
{
name: "Poor content",
results: &ContentResults{
HTMLValid: false,
Links: []LinkCheck{{Valid: true, Status: 404}},
Images: []ImageCheck{{HasAlt: false}},
HasUnsubscribe: false,
TextPlainRatio: 0.1,
ImageTextRatio: 15.0,
SuspiciousURLs: []string{"url1", "url2"},
},
minScore: 0,
maxScore: 25,
},
{
name: "Average content",
results: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: false,
TextPlainRatio: 0.5,
ImageTextRatio: 4.0,
},
minScore: 50,
maxScore: 90,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := analyzer.GetContentScore(tt.results)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
// Ensure score is capped at 100
if score > 100 {
t.Errorf("Score %v exceeds maximum of 100", score)
}
// Ensure score is not negative
if score < 0 {
t.Errorf("Score %v is negative", score)
}
})
}
}
func TestGenerateContentChecks(t *testing.T) {
tests := []struct {
name string
results *ContentResults
minChecks int
}{
{
name: "Nil results",
results: nil,
minChecks: 0,
},
{
name: "Complete results",
results: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: true,
TextContent: "Plain text",
HTMLContent: "<p>HTML text</p>",
ImageTextRatio: 3.0,
},
minChecks: 5, // HTML, Links, Images, Unsubscribe, Text consistency, Image ratio
},
{
name: "With suspicious URLs",
results: &ContentResults{
HTMLValid: true,
SuspiciousURLs: []string{"url1"},
},
minChecks: 3, // HTML, Unsubscribe, Suspicious URLs
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateContentChecks(tt.results)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Content category
for _, check := range checks {
if check.Category != api.Content {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Content)
}
}
})
}
}
// Helper functions for testing
func parseHTML(htmlStr string) (*html.Node, error) {

View file

@ -492,232 +492,3 @@ func (d *DNSAnalyzer) validateBIMI(record string) bool {
return true
}
// GenerateDNSChecks generates check results for DNS validation
func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
var checks []api.Check
if results == nil {
return checks
}
// MX record check
checks = append(checks, d.generateMXCheck(results))
// SPF record check
if results.SPFRecord != nil {
checks = append(checks, d.generateSPFCheck(results.SPFRecord))
}
// DKIM record checks
for _, dkim := range results.DKIMRecords {
checks = append(checks, d.generateDKIMCheck(&dkim))
}
// DMARC record check
if results.DMARCRecord != nil {
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
}
// BIMI record check (optional)
if results.BIMIRecord != nil {
checks = append(checks, d.generateBIMICheck(results.BIMIRecord, results.DMARCRecord))
}
return checks
}
// generateMXCheck creates a check for MX records
func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
check := api.Check{
Category: api.Dns,
Name: "MX Records",
}
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
check.Status = api.CheckStatusFail
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityCritical)
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
check.Message = results.MXRecords[0].Error
} else {
check.Message = "No valid MX records found"
}
check.Advice = api.PtrTo("Configure MX records for your domain to receive email")
} else {
check.Status = api.CheckStatusPass
check.Score = 100
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
// Add details about MX records
var mxList []string
for _, mx := range results.MXRecords {
mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority))
}
details := strings.Join(mxList, ", ")
check.Details = &details
check.Advice = api.PtrTo("Your MX records are properly configured")
}
return check
}
// generateSPFCheck creates a check for SPF records
func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "SPF Record",
}
if !spf.Valid {
if spf.Record == "" {
// If no record exists at all, it's a failure
check.Status = api.CheckStatusFail
check.Score = 0
check.Message = spf.Error
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
} else {
// If record exists but is invalid, it's a failure
check.Status = api.CheckStatusFail
check.Score = 5
check.Message = "SPF record found but appears invalid"
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
check.Details = &spf.Record
}
} else {
check.Status = api.CheckStatusPass
check.Score = 100
check.Message = "Valid SPF record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &spf.Record
check.Advice = api.PtrTo("Your SPF record is properly configured")
}
return check
}
// generateDKIMCheck creates a check for DKIM records
func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector),
}
if !dkim.Valid {
check.Status = api.CheckStatusFail
check.Score = 0
check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 100
check.Message = "Valid DKIM record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
check.Details = &details
check.Advice = api.PtrTo("Your DKIM record is properly published")
}
return check
}
// generateDMARCCheck creates a check for DMARC records
func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "DMARC Record",
}
if !dmarc.Valid {
check.Status = api.CheckStatusFail
check.Score = 0
check.Message = dmarc.Error
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
} else {
check.Status = api.CheckStatusPass
check.Score = 100
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &dmarc.Record
// Provide advice based on policy
switch dmarc.Policy {
case "none":
advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection"
check.Advice = &advice
case "quarantine":
advice := "DMARC policy is set to 'quarantine'. This provides good protection"
check.Advice = &advice
case "reject":
advice := "DMARC policy is set to 'reject'. This provides the strongest protection"
check.Advice = &advice
default:
advice := "Your DMARC record is properly configured"
check.Advice = &advice
}
}
return check
}
// generateBIMICheck creates a check for BIMI records
func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord, dmarc *DMARCRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "BIMI Record",
}
if !bimi.Valid {
// BIMI is optional, so missing record is just informational
if bimi.Record == "" {
check.Status = api.CheckStatusInfo
check.Score = 0
check.Message = "No BIMI record found (optional)"
check.Severity = api.PtrTo(api.CheckSeverityLow)
if dmarc.Policy != "quarantine" && dmarc.Policy != "reject" {
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)")
} else {
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients.")
}
} else {
// If record exists but is invalid
check.Status = api.CheckStatusWarn
check.Score = 5
check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)")
check.Details = &bimi.Record
}
} else {
check.Status = api.CheckStatusPass
check.Score = 100 // BIMI doesn't contribute to score (branding feature)
check.Message = "Valid BIMI record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
// Build details with logo and VMC URLs
var detailsParts []string
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector))
if bimi.LogoURL != "" {
detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL))
}
if bimi.VMCURL != "" {
detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL))
check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate")
} else {
check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust")
}
details := strings.Join(detailsParts, ", ")
check.Details = &details
}
return check
}

View file

@ -23,11 +23,8 @@ package analyzer
import (
"net/mail"
"strings"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewDNSAnalyzer(t *testing.T) {
@ -300,338 +297,6 @@ func TestValidateDMARC(t *testing.T) {
}
}
func TestGenerateMXCheck(t *testing.T) {
tests := []struct {
name string
results *DNSResults
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "Valid MX records",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
{Host: "mail2.example.com", Priority: 20, Valid: true},
},
},
expectedStatus: api.CheckStatusPass,
expectedScore: 10,
},
{
name: "No MX records",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Valid: false, Error: "No MX records found"},
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
{
name: "MX lookup failed",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Valid: false, Error: "DNS lookup failed"},
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateMXCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
})
}
}
func TestGenerateSPFCheck(t *testing.T) {
tests := []struct {
name string
spf *SPFRecord
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "Valid SPF",
spf: &SPFRecord{
Record: "v=spf1 include:_spf.example.com -all",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 10,
},
{
name: "Invalid SPF",
spf: &SPFRecord{
Record: "v=spf1 invalid syntax",
Valid: false,
Error: "SPF record appears malformed",
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 5,
},
{
name: "No SPF record",
spf: &SPFRecord{
Valid: false,
Error: "No SPF record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateSPFCheck(tt.spf)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
})
}
}
func TestGenerateDKIMCheck(t *testing.T) {
tests := []struct {
name string
dkim *DKIMRecord
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "Valid DKIM",
dkim: &DKIMRecord{
Selector: "default",
Domain: "example.com",
Record: "v=DKIM1; k=rsa; p=MIGfMA0...",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 10,
},
{
name: "Invalid DKIM",
dkim: &DKIMRecord{
Selector: "default",
Domain: "example.com",
Valid: false,
Error: "No DKIM record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateDKIMCheck(tt.dkim)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
if !strings.Contains(check.Name, tt.dkim.Selector) {
t.Errorf("Check name should contain selector %s", tt.dkim.Selector)
}
})
}
}
func TestGenerateDMARCCheck(t *testing.T) {
tests := []struct {
name string
dmarc *DMARCRecord
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "Valid DMARC - reject",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=reject",
Policy: "reject",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 10,
},
{
name: "Valid DMARC - quarantine",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=quarantine",
Policy: "quarantine",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 10,
},
{
name: "Valid DMARC - none",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=none",
Policy: "none",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 10,
},
{
name: "No DMARC record",
dmarc: &DMARCRecord{
Valid: false,
Error: "No DMARC record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateDMARCCheck(tt.dmarc)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
// Check that advice mentions policy for valid DMARC
if tt.dmarc.Valid && check.Advice != nil {
if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") {
t.Error("Advice should mention 'none' policy")
}
}
})
}
}
func TestGenerateDNSChecks(t *testing.T) {
tests := []struct {
name string
results *DNSResults
minChecks int
}{
{
name: "Nil results",
results: nil,
minChecks: 0,
},
{
name: "Complete results",
results: &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",
Valid: true,
},
},
DMARCRecord: &DMARCRecord{
Record: "v=DMARC1; p=quarantine",
Policy: "quarantine",
Valid: true,
},
},
minChecks: 4, // MX, SPF, DKIM, DMARC
},
{
name: "Partial results",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
},
},
minChecks: 1, // Only MX
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateDNSChecks(tt.results)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the DNS category
for _, check := range checks {
if check.Category != api.Dns {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns)
}
}
})
}
}
func TestAnalyzeDNS_NoDomain(t *testing.T) {
analyzer := NewDNSAnalyzer(5 * time.Second)
email := &EmailMessage{
Header: make(mail.Header),
// No From address
}
results := analyzer.AnalyzeDNS(email, nil)
if results == nil {
t.Fatal("Expected results, got nil")
}
if len(results.Errors) == 0 {
t.Error("Expected error when no domain can be extracted")
}
}
func TestExtractBIMITag(t *testing.T) {
tests := []struct {
name string
@ -732,89 +397,3 @@ func TestValidateBIMI(t *testing.T) {
})
}
}
func TestGenerateBIMICheck(t *testing.T) {
tests := []struct {
name string
bimi *BIMIRecord
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "Valid BIMI with logo only",
bimi: &BIMIRecord{
Selector: "default",
Domain: "example.com",
Record: "v=BIMI1; l=https://example.com/logo.svg",
LogoURL: "https://example.com/logo.svg",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0, // BIMI doesn't contribute to score
},
{
name: "Valid BIMI with VMC",
bimi: &BIMIRecord{
Selector: "default",
Domain: "example.com",
Record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
LogoURL: "https://example.com/logo.svg",
VMCURL: "https://example.com/vmc.pem",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0,
},
{
name: "No BIMI record (optional)",
bimi: &BIMIRecord{
Selector: "default",
Domain: "example.com",
Valid: false,
Error: "No BIMI record found",
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0,
},
{
name: "Invalid BIMI record",
bimi: &BIMIRecord{
Selector: "default",
Domain: "example.com",
Record: "v=BIMI1",
Valid: false,
Error: "BIMI record appears malformed",
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateBIMICheck(tt.bimi)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
if check.Name != "BIMI Record" {
t.Errorf("Name = %q, want %q", check.Name, "BIMI Record")
}
// Check details for valid BIMI with VMC
if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil {
if !strings.Contains(*check.Details, "VMC URL") {
t.Error("Details should contain VMC URL for valid BIMI with VMC")
}
}
})
}
}

View file

@ -36,56 +36,53 @@ func NewHeaderAnalyzer() *HeaderAnalyzer {
return &HeaderAnalyzer{}
}
// calculateHeaderScore evaluates email structural quality
func (h *HeaderAnalyzer) calculateHeaderScore(email *EmailMessage) int {
if email == nil {
// CalculateHeaderScore evaluates email structural quality from header analysis
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) int {
if analysis == nil || analysis.Headers == nil {
return 0
}
score := 0
requiredHeaders := 0
presentHeaders := 0
headers := *analysis.Headers
// Check required headers (RFC 5322)
headers := map[string]bool{
"From": false,
"Date": false,
"Message-ID": false,
}
// Check required headers (RFC 5322) - 40 points
requiredHeaders := []string{"from", "date", "message-id"}
requiredCount := len(requiredHeaders)
presentRequired := 0
for header := range headers {
requiredHeaders++
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
headers[header] = true
presentHeaders++
for _, headerName := range requiredHeaders {
if check, exists := headers[headerName]; exists && check.Present {
presentRequired++
}
}
// Score based on required headers (40 points)
if presentHeaders == requiredHeaders {
if presentRequired == requiredCount {
score += 40
} else {
score += int(40 * (float32(presentHeaders) / float32(requiredHeaders)))
score += int(40 * (float32(presentRequired) / float32(requiredCount)))
}
// Check recommended headers (30 points)
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
recommendedPresent := 0
for _, header := range recommendedHeaders {
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
recommendedPresent++
recommendedHeaders := []string{"subject", "to", "reply-to"}
recommendedCount := len(recommendedHeaders)
presentRecommended := 0
for _, headerName := range recommendedHeaders {
if check, exists := headers[headerName]; exists && check.Present {
presentRecommended++
}
}
score += int(30 * (float32(recommendedPresent) / float32(len(recommendedHeaders))))
score += int(30 * (float32(presentRecommended) / float32(recommendedCount)))
// Check for proper MIME structure (20 points)
if len(email.Parts) > 0 {
if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure {
score += 20
}
// Check Message-ID format (10 point)
if messageID := email.GetHeaderValue("Message-ID"); messageID != "" {
if h.isValidMessageID(messageID) {
// Check Message-ID format (10 points)
if check, exists := headers["message-id"]; exists && check.Present {
// If Valid is set and true, award points
if check.Valid != nil && *check.Valid {
score += 10
}
}
@ -123,181 +120,187 @@ func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool {
return len(parts[0]) > 0 && len(parts[1]) > 0
}
// GenerateHeaderChecks creates checks for email header quality
func (h *HeaderAnalyzer) GenerateHeaderChecks(email *EmailMessage) []api.Check {
var checks []api.Check
// GenerateHeaderAnalysis creates structured header analysis from email
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.HeaderAnalysis {
if email == nil {
return checks
return nil
}
// Required headers check
checks = append(checks, h.generateRequiredHeadersCheck(email))
analysis := &api.HeaderAnalysis{}
// Recommended headers check
checks = append(checks, h.generateRecommendedHeadersCheck(email))
// Check for proper MIME structure
analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0)
// Message-ID check
checks = append(checks, h.generateMessageIDCheck(email))
// Initialize headers map
headers := make(map[string]api.HeaderCheck)
// MIME structure check
checks = append(checks, h.generateMIMEStructureCheck(email))
// Check required headers
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
for _, headerName := range requiredHeaders {
check := h.checkHeader(email, headerName, "required")
headers[strings.ToLower(headerName)] = *check
}
return checks
// Check recommended headers
recommendedHeaders := []string{"Reply-To", "Return-Path"}
for _, headerName := range recommendedHeaders {
check := h.checkHeader(email, headerName, "recommended")
headers[strings.ToLower(headerName)] = *check
}
// Check optional headers
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post", "Precedence"}
for _, headerName := range optionalHeaders {
check := h.checkHeader(email, headerName, "optional")
headers[strings.ToLower(headerName)] = *check
}
analysis.Headers = &headers
// Domain alignment
domainAlignment := h.analyzeDomainAlignment(email)
if domainAlignment != nil {
analysis.DomainAlignment = domainAlignment
}
// Header issues
issues := h.findHeaderIssues(email)
if len(issues) > 0 {
analysis.Issues = &issues
}
return analysis
}
// generateRequiredHeadersCheck checks for required RFC 5322 headers
func (h *HeaderAnalyzer) generateRequiredHeadersCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Required Headers",
// checkHeader checks if a header is present and valid
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck {
value := email.GetHeaderValue(headerName)
present := email.HasHeader(headerName) && value != ""
importanceEnum := api.HeaderCheckImportance(importance)
check := &api.HeaderCheck{
Present: present,
Importance: &importanceEnum,
}
requiredHeaders := []string{"From", "Date", "Message-ID"}
missing := []string{}
if present {
check.Value = &value
// Validate specific headers
valid := true
var headerIssues []string
switch headerName {
case "Message-ID":
if !h.isValidMessageID(value) {
valid = false
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
}
case "Date":
// Could add date validation here
}
check.Valid = &valid
if len(headerIssues) > 0 {
check.Issues = &headerIssues
}
} else {
valid := false
check.Valid = &valid
if importance == "required" {
issues := []string{"Required header is missing"}
check.Issues = &issues
}
}
return check
}
// analyzeDomainAlignment checks domain alignment between headers
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment {
alignment := &api.DomainAlignment{
Aligned: api.PtrTo(true),
}
// Extract From domain
fromAddr := email.GetHeaderValue("From")
if fromAddr != "" {
domain := h.extractDomain(fromAddr)
if domain != "" {
alignment.FromDomain = &domain
}
}
// Extract Return-Path domain
returnPath := email.GetHeaderValue("Return-Path")
if returnPath != "" {
domain := h.extractDomain(returnPath)
if domain != "" {
alignment.ReturnPathDomain = &domain
}
}
// Check alignment
issues := []string{}
if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil {
if *alignment.FromDomain != *alignment.ReturnPathDomain {
*alignment.Aligned = false
issues = append(issues, "Return-Path domain does not match From domain")
}
}
if len(issues) > 0 {
alignment.Issues = &issues
}
return alignment
}
// extractDomain extracts domain from email address
func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
// Remove angle brackets if present
emailAddr = strings.Trim(emailAddr, "<> ")
// Find @ symbol
atIndex := strings.LastIndex(emailAddr, "@")
if atIndex == -1 {
return ""
}
domain := emailAddr[atIndex+1:]
// Remove any trailing >
domain = strings.TrimRight(domain, ">")
return domain
}
// findHeaderIssues identifies issues with headers
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue {
var issues []api.HeaderIssue
// Check for missing required headers
requiredHeaders := []string{"From", "Date", "Message-ID"}
for _, header := range requiredHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
missing = append(missing, header)
issues = append(issues, api.HeaderIssue{
Header: header,
Severity: api.HeaderIssueSeverityCritical,
Message: fmt.Sprintf("Required header '%s' is missing", header),
Advice: api.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
})
}
}
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 4.0
check.Grade = ScoreToCheckGrade((4.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All required headers are present"
check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
} else {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
check.Details = &details
}
return check
}
// generateRecommendedHeadersCheck checks for recommended headers
func (h *HeaderAnalyzer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Recommended Headers",
}
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
missing := []string{}
for _, header := range recommendedHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
missing = append(missing, header)
}
}
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 30
check.Grade = ScoreToCheckGrade((3.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All recommended headers are present"
check.Advice = api.PtrTo("Your email includes all recommended headers")
} else if len(missing) < len(recommendedHeaders) {
check.Status = api.CheckStatusWarn
check.Score = 15
check.Grade = ScoreToCheckGrade((1.5 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
check.Details = &details
} else {
check.Status = api.CheckStatusWarn
check.Score = 0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Missing all recommended headers"
check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
}
return check
}
// generateMessageIDCheck validates Message-ID header
func (h *HeaderAnalyzer) generateMessageIDCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Message-ID Format",
}
// Check Message-ID format
messageID := email.GetHeaderValue("Message-ID")
if messageID == "" {
check.Status = api.CheckStatusFail
check.Score = 0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Message = "Message-ID header is missing"
check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
} else if !h.isValidMessageID(messageID) {
check.Status = api.CheckStatusWarn
check.Score = 5
check.Grade = ScoreToCheckGrade((0.5 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Message-ID format is invalid"
check.Advice = api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>")
check.Details = &messageID
} else {
check.Status = api.CheckStatusPass
check.Score = 10
check.Grade = ScoreToCheckGrade((1.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Message-ID is properly formatted"
check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
check.Details = &messageID
if messageID != "" && !h.isValidMessageID(messageID) {
issues = append(issues, api.HeaderIssue{
Header: "Message-ID",
Severity: api.HeaderIssueSeverityMedium,
Message: "Message-ID format is invalid",
Advice: api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
})
}
return check
}
// generateMIMEStructureCheck validates MIME structure
func (h *HeaderAnalyzer) generateMIMEStructureCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "MIME Structure",
}
if len(email.Parts) == 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "No MIME parts detected"
check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
} else {
check.Status = api.CheckStatusPass
check.Score = 2.0
check.Grade = ScoreToCheckGrade((2.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
check.Advice = api.PtrTo("Your email has proper MIME structure")
// Add details about parts
partTypes := []string{}
for _, part := range email.Parts {
if part.ContentType != "" {
partTypes = append(partTypes, part.ContentType)
}
}
if len(partTypes) > 0 {
details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", "))
check.Details = &details
}
}
return check
return issues
}

View file

@ -25,8 +25,6 @@ import (
"net/mail"
"net/textproto"
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestCalculateHeaderScore(t *testing.T) {
@ -109,95 +107,70 @@ func TestCalculateHeaderScore(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := analyzer.calculateHeaderScore(tt.email)
// Generate header analysis first
analysis := analyzer.GenerateHeaderAnalysis(tt.email)
score := analyzer.CalculateHeaderScore(analysis)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
})
}
}
func TestGenerateRequiredHeadersCheck(t *testing.T) {
func TestCheckHeader(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
expectedStatus api.CheckStatus
expectedScore int
name string
headerName string
headerValue string
importance string
expectedPresent bool
expectedValid bool
expectedIssuesLen int
}{
{
name: "All required headers present",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
From: &mail.Address{Address: "sender@example.com"},
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
},
expectedStatus: api.CheckStatusPass,
expectedScore: 40,
name: "Valid Message-ID",
headerName: "Message-ID",
headerValue: "<abc123@example.com>",
importance: "required",
expectedPresent: true,
expectedValid: true,
expectedIssuesLen: 0,
},
{
name: "Missing all required headers",
email: &EmailMessage{
Header: make(mail.Header),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
name: "Invalid Message-ID format",
headerName: "Message-ID",
headerValue: "invalid-message-id",
importance: "required",
expectedPresent: true,
expectedValid: false,
expectedIssuesLen: 1,
},
{
name: "Missing some required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
}),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateRequiredHeadersCheck(tt.email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
}
})
}
}
func TestGenerateMessageIDCheck(t *testing.T) {
tests := []struct {
name string
messageID string
expectedStatus api.CheckStatus
}{
{
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expectedStatus: api.CheckStatusPass,
name: "Missing required header",
headerName: "From",
headerValue: "",
importance: "required",
expectedPresent: false,
expectedValid: false,
expectedIssuesLen: 1,
},
{
name: "Invalid Message-ID format",
messageID: "invalid-message-id",
expectedStatus: api.CheckStatusWarn,
name: "Missing optional header",
headerName: "Reply-To",
headerValue: "",
importance: "optional",
expectedPresent: false,
expectedValid: false,
expectedIssuesLen: 0,
},
{
name: "Missing Message-ID",
messageID: "",
expectedStatus: api.CheckStatusFail,
name: "Valid Date header",
headerName: "Date",
headerValue: "Mon, 01 Jan 2024 12:00:00 +0000",
importance: "required",
expectedPresent: true,
expectedValid: true,
expectedIssuesLen: 0,
},
}
@ -207,86 +180,77 @@ func TestGenerateMessageIDCheck(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Message-ID": tt.messageID,
tt.headerName: tt.headerValue,
}),
}
check := analyzer.generateMessageIDCheck(email)
check := analyzer.checkHeader(email, tt.headerName, tt.importance)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
if check.Present != tt.expectedPresent {
t.Errorf("Present = %v, want %v", check.Present, tt.expectedPresent)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
if check.Valid != nil && *check.Valid != tt.expectedValid {
t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid)
}
if check.Importance == nil {
t.Error("Importance is nil")
} else if string(*check.Importance) != tt.importance {
t.Errorf("Importance = %v, want %v", *check.Importance, tt.importance)
}
issuesLen := 0
if check.Issues != nil {
issuesLen = len(*check.Issues)
}
if issuesLen != tt.expectedIssuesLen {
t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectedIssuesLen)
}
})
}
}
func TestGenerateMIMEStructureCheck(t *testing.T) {
tests := []struct {
name string
parts []MessagePart
expectedStatus api.CheckStatus
}{
{
name: "With MIME parts",
parts: []MessagePart{
{ContentType: "text/plain", Content: "test"},
{ContentType: "text/html", Content: "<p>test</p>"},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No MIME parts",
parts: []MessagePart{},
expectedStatus: api.CheckStatusWarn,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: make(mail.Header),
Parts: tt.parts,
}
check := analyzer.generateMIMEStructureCheck(email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateHeaderChecks(t *testing.T) {
func TestHeaderAnalyzer_IsValidMessageID(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minChecks int
messageID string
expected bool
}{
{
name: "Nil email",
email: nil,
minChecks: 0,
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expected: true,
},
{
name: "Complete email",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minChecks: 4, // Required, Recommended, Message-ID, MIME
name: "Valid with complex local part",
messageID: "<complex.id-123_xyz@subdomain.example.com>",
expected: true,
},
{
name: "Missing angle brackets",
messageID: "abc123@example.com",
expected: false,
},
{
name: "Missing @ symbol",
messageID: "<abc123example.com>",
expected: false,
},
{
name: "Empty local part",
messageID: "<@example.com>",
expected: false,
},
{
name: "Empty domain",
messageID: "<abc123@>",
expected: false,
},
{
name: "Multiple @ symbols",
messageID: "<abc@123@example.com>",
expected: false,
},
}
@ -294,17 +258,126 @@ func TestGenerateHeaderChecks(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateHeaderChecks(tt.email)
result := analyzer.isValidMessageID(tt.messageID)
if result != tt.expected {
t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected)
}
})
}
}
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
func TestHeaderAnalyzer_ExtractDomain(t *testing.T) {
tests := []struct {
name string
email string
expected string
}{
{
name: "Simple email",
email: "user@example.com",
expected: "example.com",
},
{
name: "Email with angle brackets",
email: "<user@example.com>",
expected: "example.com",
},
{
name: "Email with display name",
email: "User Name <user@example.com>",
expected: "example.com",
},
{
name: "Email with spaces",
email: " user@example.com ",
expected: "example.com",
},
{
name: "Invalid email",
email: "not-an-email",
expected: "",
},
{
name: "Empty string",
email: "",
expected: "",
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDomain(tt.email)
if result != tt.expected {
t.Errorf("extractDomain(%q) = %q, want %q", tt.email, result, tt.expected)
}
})
}
}
func TestAnalyzeDomainAlignment(t *testing.T) {
tests := []struct {
name string
fromHeader string
returnPath string
expectAligned bool
expectIssuesLen int
}{
{
name: "Aligned domains",
fromHeader: "sender@example.com",
returnPath: "bounce@example.com",
expectAligned: true,
expectIssuesLen: 0,
},
{
name: "Misaligned domains",
fromHeader: "sender@example.com",
returnPath: "bounce@different.com",
expectAligned: false,
expectIssuesLen: 1,
},
{
name: "Only From header",
fromHeader: "sender@example.com",
returnPath: "",
expectAligned: true,
expectIssuesLen: 0,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": tt.fromHeader,
"Return-Path": tt.returnPath,
}),
}
// Verify all checks have the Headers category
for _, check := range checks {
if check.Category != api.Headers {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers)
}
alignment := analyzer.analyzeDomainAlignment(email)
if alignment == nil {
t.Fatal("Expected non-nil alignment")
}
if alignment.Aligned == nil {
t.Fatal("Expected non-nil Aligned field")
}
if *alignment.Aligned != tt.expectAligned {
t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectAligned)
}
issuesLen := 0
if alignment.Issues != nil {
issuesLen = len(*alignment.Issues)
}
if issuesLen != tt.expectIssuesLen {
t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectIssuesLen)
}
})
}

View file

@ -68,24 +68,15 @@ func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker {
// RBLResults represents the results of RBL checks
type RBLResults struct {
Checks map[string][]RBLCheck // Map of IP -> list of RBL checks for that IP
Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP
IPsChecked []string
ListedCount int
}
// RBLCheck represents a single RBL check result
// Note: IP is not included here as it's used as the map key in the API
type RBLCheck struct {
RBL string
Listed bool
Response string
Error string
}
// CheckEmail checks all IPs found in the email headers against RBLs
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
results := &RBLResults{
Checks: make(map[string][]RBLCheck),
Checks: make(map[string][]api.BlacklistCheck),
}
// Extract IPs from Received headers
@ -179,15 +170,15 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool {
}
// checkIP checks a single IP against a single RBL
func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck {
check := RBLCheck{
RBL: rbl,
func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
check := api.BlacklistCheck{
Rbl: rbl,
}
// Reverse the IP for DNSBL query
reversedIP := r.reverseIP(ip)
if reversedIP == "" {
check.Error = "Failed to reverse IP address"
check.Error = api.PtrTo("Failed to reverse IP address")
return check
}
@ -208,19 +199,19 @@ func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck {
}
}
// Other DNS errors
check.Error = fmt.Sprintf("DNS lookup failed: %v", err)
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
return check
}
// If we got a response, check the return code
if len(addrs) > 0 {
check.Response = addrs[0] // Return code (e.g., 127.0.0.2)
check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2)
// Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255
// These indicate RBL operational issues, not actual listings
if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" {
check.Listed = false
check.Error = fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0])
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0]))
} else {
// Normal listing response
check.Listed = true
@ -248,8 +239,8 @@ func (r *RBLChecker) reverseIP(ipStr string) string {
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
}
// GetBlacklistScore calculates the blacklist contribution to deliverability
func (r *RBLChecker) GetBlacklistScore(results *RBLResults) int {
// CalculateRBLScore calculates the blacklist contribution to deliverability
func (r *RBLChecker) CalculateRBLScore(results *RBLResults) int {
if results == nil || len(results.IPsChecked) == 0 {
// No IPs to check, give benefit of doubt
return 100
@ -258,159 +249,6 @@ func (r *RBLChecker) GetBlacklistScore(results *RBLResults) int {
return 100 - results.ListedCount*100/len(r.RBLs)
}
// GenerateRBLChecks generates check results for RBL analysis
func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check {
var checks []api.Check
if results == nil {
return checks
}
// If no IPs were checked, add a warning
if len(results.IPsChecked) == 0 {
checks = append(checks, api.Check{
Category: api.Blacklist,
Name: "RBL Check",
Status: api.CheckStatusWarn,
Score: 50,
Grade: ScoreToCheckGrade(50),
Message: "No public IP addresses found to check",
Severity: api.PtrTo(api.CheckSeverityLow),
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
})
return checks
}
// Create a summary check
summaryCheck := r.generateSummaryCheck(results)
checks = append(checks, summaryCheck)
// Create individual checks for each listing and RBL errors
for ip, rblChecks := range results.Checks {
for _, check := range rblChecks {
if check.Listed {
detailCheck := r.generateListingCheck(ip, &check)
checks = append(checks, detailCheck)
} else if check.Error != "" && strings.Contains(check.Error, "RBL operational issue") {
// Generate info check for RBL errors
detailCheck := r.generateRBLErrorCheck(ip, &check)
checks = append(checks, detailCheck)
}
}
}
return checks
}
// generateSummaryCheck creates an overall RBL summary check
func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
check := api.Check{
Category: api.Blacklist,
Name: "RBL Summary",
}
score := r.GetBlacklistScore(results)
check.Score = score
check.Grade = ScoreToCheckGrade(score)
// Calculate total checks across all IPs
totalChecks := 0
for _, rblChecks := range results.Checks {
totalChecks += len(rblChecks)
}
listedCount := results.ListedCount
if listedCount == 0 {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your sending IP has a good reputation")
} else if listedCount == 1 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
} else if listedCount <= 3 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
} else {
check.Status = api.CheckStatusFail
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL")
}
// Add details about IPs checked
if len(results.IPsChecked) > 0 {
details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", "))
check.Details = &details
}
return check
}
// generateListingCheck creates a check for a specific RBL listing
func (r *RBLChecker) generateListingCheck(ip string, rblCheck *RBLCheck) api.Check {
check := api.Check{
Category: api.Blacklist,
Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
Status: api.CheckStatusFail,
Score: 0,
Grade: ScoreToCheckGrade(0),
}
check.Message = fmt.Sprintf("IP %s is listed on %s", ip, rblCheck.RBL)
// Determine severity based on which RBL
if strings.Contains(rblCheck.RBL, "spamhaus") {
check.Severity = api.PtrTo(api.CheckSeverityCritical)
advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
check.Advice = &advice
} else if strings.Contains(rblCheck.RBL, "spamcop") {
check.Severity = api.PtrTo(api.CheckSeverityHigh)
advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
check.Advice = &advice
} else {
check.Severity = api.PtrTo(api.CheckSeverityHigh)
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
check.Advice = &advice
}
// Add response code details
if rblCheck.Response != "" {
details := fmt.Sprintf("Response: %s", rblCheck.Response)
check.Details = &details
}
return check
}
// generateRBLErrorCheck creates an info-level check for RBL operational errors
func (r *RBLChecker) generateRBLErrorCheck(ip string, rblCheck *RBLCheck) api.Check {
check := api.Check{
Category: api.Blacklist,
Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
Status: api.CheckStatusInfo,
Score: 0, // No penalty for RBL operational issues
Grade: ScoreToCheckGrade(-1),
Severity: api.PtrTo(api.CheckSeverityInfo),
}
check.Message = fmt.Sprintf("RBL %s returned an error code for IP %s", rblCheck.RBL, ip)
advice := fmt.Sprintf("The RBL %s is experiencing operational issues (error code: %s).", rblCheck.RBL, rblCheck.Response)
check.Advice = &advice
if rblCheck.Response != "" {
details := fmt.Sprintf("Error code: %s (RBL operational issue, not a listing)", rblCheck.Response)
check.Details = &details
}
return check
}
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
var listedIPs []string
@ -434,7 +272,7 @@ func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
if rblChecks, exists := results.Checks[ip]; exists {
for _, check := range rblChecks {
if check.Listed {
rbls = append(rbls, check.RBL)
rbls = append(rbls, check.Rbl)
}
}
}

View file

@ -23,11 +23,8 @@ package analyzer
import (
"net/mail"
"strings"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewRBLChecker(t *testing.T) {
@ -327,7 +324,7 @@ func TestGetBlacklistScore(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := checker.GetBlacklistScore(tt.results)
score := checker.CalculateRBLScore(tt.results)
if score != tt.expectedScore {
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
}
@ -335,212 +332,6 @@ func TestGetBlacklistScore(t *testing.T) {
}
}
func TestGenerateSummaryCheck(t *testing.T) {
tests := []struct {
name string
results *RBLResults
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "Not listed",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
Checks: map[string][]RBLCheck{
"198.51.100.1": make([]RBLCheck, 6), // 6 default RBLs
},
},
expectedStatus: api.CheckStatusPass,
expectedScore: 200,
},
{
name: "Listed on 1 RBL",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 1,
Checks: map[string][]RBLCheck{
"198.51.100.1": make([]RBLCheck, 6),
},
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 100,
},
{
name: "Listed on 2 RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
Checks: map[string][]RBLCheck{
"198.51.100.1": make([]RBLCheck, 6),
},
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 50,
},
{
name: "Listed on 4+ RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 4,
Checks: map[string][]RBLCheck{
"198.51.100.1": make([]RBLCheck, 6),
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := checker.generateSummaryCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Blacklist {
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
}
})
}
}
func TestGenerateListingCheck(t *testing.T) {
tests := []struct {
name string
rblCheck *RBLCheck
expectedStatus api.CheckStatus
expectedSeverity api.CheckSeverity
}{
{
name: "Spamhaus listing",
rblCheck: &RBLCheck{
RBL: "zen.spamhaus.org",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.CheckSeverityCritical,
},
{
name: "SpamCop listing",
rblCheck: &RBLCheck{
RBL: "bl.spamcop.net",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.CheckSeverityHigh,
},
{
name: "Other RBL listing",
rblCheck: &RBLCheck{
RBL: "dnsbl.sorbs.net",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.CheckSeverityHigh,
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := checker.generateListingCheck("198.51.100.1", tt.rblCheck)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Severity == nil || *check.Severity != tt.expectedSeverity {
t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity)
}
if check.Category != api.Blacklist {
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
}
if !strings.Contains(check.Name, tt.rblCheck.RBL) {
t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL)
}
})
}
}
func TestGenerateRBLChecks(t *testing.T) {
tests := []struct {
name string
results *RBLResults
minChecks int
}{
{
name: "Nil results",
results: nil,
minChecks: 0,
},
{
name: "No IPs checked",
results: &RBLResults{
IPsChecked: []string{},
},
minChecks: 1, // Warning check
},
{
name: "Not listed on any RBL",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
Checks: map[string][]RBLCheck{
"198.51.100.1": {
{RBL: "zen.spamhaus.org", Listed: false},
{RBL: "bl.spamcop.net", Listed: false},
},
},
},
minChecks: 1, // Summary check only
},
{
name: "Listed on 2 RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
Checks: map[string][]RBLCheck{
"198.51.100.1": {
{RBL: "zen.spamhaus.org", Listed: true},
{RBL: "bl.spamcop.net", Listed: true},
{RBL: "dnsbl.sorbs.net", Listed: false},
},
},
},
minChecks: 3, // Summary + 2 listing checks
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := checker.GenerateRBLChecks(tt.results)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Blacklist category
for _, check := range checks {
if check.Category != api.Blacklist {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist)
}
}
})
}
}
func TestGetUniqueListedIPs(t *testing.T) {
results := &RBLResults{
Checks: map[string][]RBLCheck{

View file

@ -61,10 +61,11 @@ func NewReportGenerator(
type AnalysisResults struct {
Email *EmailMessage
Authentication *api.AuthenticationResults
SpamAssassin *SpamAssassinResult
DNS *DNSResults
RBL *RBLResults
Content *ContentResults
DNS *DNSResults
Headers *api.HeaderAnalysis
RBL *RBLResults
SpamAssassin *SpamAssassinResult
}
// AnalyzeEmail performs complete email analysis
@ -75,10 +76,11 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
// 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)
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication)
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email)
results.RBL = r.rblChecker.CheckEmail(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
return results
}
@ -94,77 +96,65 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
CreatedAt: now,
}
// Collect all checks from different analyzers
checks := []api.Check{}
// Authentication checks
// Calculate scores directly from analyzers (no more checks array)
authScore := 0
if results.Authentication != nil {
authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication)
checks = append(checks, authChecks...)
authScore = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication)
}
// 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
contentScore := 0
if results.Content != nil {
contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content)
checks = append(checks, contentChecks...)
contentScore = r.contentAnalyzer.CalculateContentScore(results.Content)
}
// Header checks
headerChecks := r.headerAnalyzer.GenerateHeaderChecks(results.Email)
checks = append(checks, headerChecks...)
report.Checks = checks
// Summarize scores by category
categoryCounts := make(map[api.CheckCategory]int)
categoryTotals := make(map[api.CheckCategory]int)
for _, check := range checks {
if check.Status == "info" {
continue
}
categoryCounts[check.Category]++
categoryTotals[check.Category] += check.Score
headerScore := 0
if results.Headers != nil {
headerScore = r.headerAnalyzer.CalculateHeaderScore(results.Headers)
}
// Calculate mean scores for each category
calcCategoryScore := func(category api.CheckCategory) int {
if count := categoryCounts[category]; count > 0 {
return categoryTotals[category] / count
}
return 0
blacklistScore := 0
if results.RBL != nil {
blacklistScore = r.rblChecker.CalculateRBLScore(results.RBL)
}
spamScore := 0
if results.SpamAssassin != nil {
spamScore = r.scorer.CalculateSpamScore(results.SpamAssassin)
}
report.Summary = &api.ScoreSummary{
AuthenticationScore: calcCategoryScore(api.Authentication),
BlacklistScore: calcCategoryScore(api.Blacklist),
ContentScore: calcCategoryScore(api.Content),
HeaderScore: calcCategoryScore(api.Headers),
SpamScore: calcCategoryScore(api.Spam),
AuthenticationScore: authScore,
BlacklistScore: blacklistScore,
ContentScore: contentScore,
HeaderScore: headerScore,
SpamScore: spamScore,
}
// 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 {
dnsRecords := r.buildDNSRecords(results.DNS)
if len(dnsRecords) > 0 {
report.DnsRecords = &dnsRecords
}
}
// 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 SpamAssassin result
if results.SpamAssassin != nil {
report.Spamassassin = &api.SpamAssassinResult{
@ -182,39 +172,6 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
}
}
// Add DNS records
if results.DNS != nil {
dnsRecords := r.buildDNSRecords(results.DNS)
if len(dnsRecords) > 0 {
report.DnsRecords = &dnsRecords
}
}
// Add blacklist checks as a map of IP -> array of BlacklistCheck
if results.RBL != nil && len(results.RBL.Checks) > 0 {
blacklistMap := make(map[string][]api.BlacklistCheck)
// Convert internal RBL checks to API format
for ip, rblChecks := range results.RBL.Checks {
apiChecks := make([]api.BlacklistCheck, 0, len(rblChecks))
for _, check := range rblChecks {
blCheck := api.BlacklistCheck{
Rbl: check.RBL,
Listed: check.Listed,
}
if check.Response != "" {
blCheck.Response = &check.Response
}
apiChecks = append(apiChecks, blCheck)
}
blacklistMap[ip] = apiChecks
}
if len(blacklistMap) > 0 {
report.Blacklists = &blacklistMap
}
}
// Add raw headers
if results.Email != nil && results.Email.RawHeaders != "" {
report.RawHeaders = &results.Email.RawHeaders

View file

@ -24,7 +24,6 @@ package analyzer
import (
"net/mail"
"net/textproto"
"strings"
"testing"
"time"
@ -77,20 +76,6 @@ func TestAnalyzeEmail(t *testing.T) {
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 > 100 {
t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore)
}
}
func TestGenerateReport(t *testing.T) {
@ -125,10 +110,6 @@ func TestGenerateReport(t *testing.T) {
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 {
@ -147,22 +128,6 @@ func TestGenerateReport(t *testing.T) {
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) {
@ -319,135 +284,6 @@ func TestGenerateRawEmail(t *testing.T) {
}
}
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: 50,
Grade: ScoreToReportGrade(50),
AuthScore: 15,
SpamScore: 10,
BlacklistScore: 15,
ContentScore: 5,
HeaderScore: 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: 85,
Grade: ScoreToReportGrade(85),
AuthScore: 25,
SpamScore: 18,
BlacklistScore: 20,
ContentScore: 15,
HeaderScore: 7,
CategoryBreakdown: map[string]CategoryScore{
"Authentication": {Score: 25, Status: "Pass"},
"Spam Filters": {Score: 18, Status: "Pass"},
"Blacklists": {Score: 20, Status: "Pass"},
"Content Quality": {Score: 15, Status: "Warn"},
"Email Structure": {Score: 7, 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 {
@ -484,21 +320,3 @@ func createTestEmailWithSpamAssassin() *EmailMessage {
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
}

View file

@ -45,11 +45,6 @@ func ScoreToGrade(score int) string {
}
}
// ScoreToCheckGrade converts a percentage score to an api.CheckGrade
func ScoreToCheckGrade(score int) api.CheckGrade {
return api.CheckGrade(ScoreToGrade(score))
}
// ScoreToReportGrade converts a percentage score to an api.ReportGrade
func ScoreToReportGrade(score int) api.ReportGrade {
return api.ReportGrade(ScoreToGrade(score))
@ -62,3 +57,29 @@ type DeliverabilityScorer struct{}
func NewDeliverabilityScorer() *DeliverabilityScorer {
return &DeliverabilityScorer{}
}
// CalculateSpamScore calculates spam score from SpamAssassin results
// Returns a score from 0-100 where higher is better
func (s *DeliverabilityScorer) CalculateSpamScore(result *SpamAssassinResult) int {
if result == nil {
return 100 // No spam scan results, assume good
}
// SpamAssassin score typically ranges from -10 to +20
// Score < 0 is very likely ham (good)
// Score 0-5 is threshold range (configurable, usually 5.0)
// Score > 5 is likely spam
score := result.Score
// Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better)
if score <= 0 {
return 100 // Perfect score for ham
} else if score >= result.RequiredScore {
return 0 // Failed spam test
} else {
// Linear scale between 0 and required threshold
percentage := (score / result.RequiredScore) * 100
return int(100 - percentage)
}
}

View file

@ -21,254 +21,4 @@
package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewDeliverabilityScorer(t *testing.T) {
scorer := NewDeliverabilityScorer()
if scorer == nil {
t.Fatal("Expected scorer, got nil")
}
}
func TestIsValidMessageID(t *testing.T) {
tests := []struct {
name string
messageID string
expected bool
}{
{
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expected: true,
},
{
name: "Valid with UUID",
messageID: "<550e8400-e29b-41d4-a716-446655440000@example.com>",
expected: true,
},
{
name: "Missing angle brackets",
messageID: "abc123@example.com",
expected: false,
},
{
name: "Missing @ symbol",
messageID: "<abc123example.com>",
expected: false,
},
{
name: "Multiple @ symbols",
messageID: "<abc@123@example.com>",
expected: false,
},
{
name: "Empty local part",
messageID: "<@example.com>",
expected: false,
},
{
name: "Empty domain part",
messageID: "<abc123@>",
expected: false,
},
{
name: "Empty",
messageID: "",
expected: false,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.isValidMessageID(tt.messageID)
if result != tt.expected {
t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected)
}
})
}
}
func TestCalculateScore(t *testing.T) {
tests := []struct {
name string
authResults *api.AuthenticationResults
spamResult *SpamAssassinResult
rblResults *RBLResults
contentResults *ContentResults
email *EmailMessage
minScore int
maxScore int
expectedGrade string
}{
{
name: "Perfect email",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{Result: api.AuthResultResultPass},
},
spamResult: &SpamAssassinResult{
Score: -1.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{IP: "192.0.2.1", Listed: false},
},
},
contentResults: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: true,
TextPlainRatio: 0.8,
ImageTextRatio: 3.0,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
MessageID: "<abc123@example.com>",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 90.0,
maxScore: 100.0,
expectedGrade: "A+",
},
{
name: "Poor email - auth issues",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultFail},
Dkim: &[]api.AuthResult{},
Dmarc: nil,
},
spamResult: &SpamAssassinResult{
Score: 8.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{
IP: "192.0.2.1",
RBL: "zen.spamhaus.org",
Listed: true,
},
},
ListedCount: 1,
},
contentResults: &ContentResults{
HTMLValid: false,
Links: []LinkCheck{{Valid: true, Status: 404}},
HasUnsubscribe: false,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
}),
},
minScore: 0.0,
maxScore: 50.0,
expectedGrade: "C",
},
{
name: "Average email",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: nil,
},
spamResult: &SpamAssassinResult{
Score: 4.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{IP: "192.0.2.1", Listed: false},
},
},
contentResults: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
HasUnsubscribe: false,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 60.0,
maxScore: 90.0,
expectedGrade: "A",
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.CalculateScore(
tt.authResults,
tt.spamResult,
tt.rblResults,
tt.contentResults,
tt.email,
)
if result == nil {
t.Fatal("Expected result, got nil")
}
// Check overall score
if result.OverallScore < tt.minScore || result.OverallScore > tt.maxScore {
t.Errorf("OverallScore = %v, want between %v and %v", result.OverallScore, tt.minScore, tt.maxScore)
}
// Check rating
if result.Grade != api.ReportGrade(tt.expectedGrade) {
t.Errorf("Grade = %q, want %q", result.Grade, tt.expectedGrade)
}
// Verify score is within bounds
if result.OverallScore < 0.0 || result.OverallScore > 100.0 {
t.Errorf("OverallScore %v is out of bounds [0.0, 100.0]", result.OverallScore)
}
// Verify category breakdown exists
if len(result.CategoryBreakdown) != 5 {
t.Errorf("Expected 5 categories, got %d", len(result.CategoryBreakdown))
}
// Verify recommendations exist
if len(result.Recommendations) == 0 && result.Grade != "A+" {
t.Error("Expected recommendations for non-excellent rating")
}
// Verify category scores add up to overall score
totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
if totalCategoryScore != result.OverallScore {
t.Errorf("Category scores sum (%d) doesn't match overall score (%d)",
totalCategoryScore, result.OverallScore)
}
})
}
}
import ()

View file

@ -22,13 +22,10 @@
package analyzer
import (
"fmt"
"math"
"regexp"
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
@ -198,131 +195,3 @@ func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult)
// Linear scaling based on how negative/low the score is
return 100 - int(math.Round(25*score/required))
}
// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis
func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check {
var checks []api.Check
if result == nil {
checks = append(checks, api.Check{
Category: api.Spam,
Name: "SpamAssassin Analysis",
Status: api.CheckStatusWarn,
Score: 0.0,
Grade: ScoreToCheckGrade(0.0),
Message: "No SpamAssassin headers found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
})
return checks
}
// Main spam score check
mainCheck := a.generateMainSpamCheck(result)
checks = append(checks, mainCheck)
// Add checks for significant spam tests (score > 1.0 or < -1.0)
for _, test := range result.Tests {
if detail, ok := result.TestDetails[test]; ok {
if detail.Score > 1.0 || detail.Score < -1.0 {
check := a.generateTestCheck(detail)
checks = append(checks, check)
}
}
}
return checks
}
// generateMainSpamCheck creates the main spam score check
func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check {
check := api.Check{
Category: api.Spam,
Name: "SpamAssassin Score",
}
score := result.Score
required := result.RequiredScore
if required == 0 {
required = 5.0
}
check.Score = a.GetSpamAssassinScore(result)
check.Grade = ScoreToCheckGrade(check.Score)
// Determine status and message based on score
if score <= 0 {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
} else if score < required {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your email passes spam filters")
} else if score < required*1.5 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
} else if score < required*2 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
} else {
check.Status = api.CheckStatusFail
check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
}
// Add details
if len(result.Tests) > 0 {
details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", "))
if len(result.Tests) > 5 {
details += fmt.Sprintf(" and %d more", len(result.Tests)-5)
}
check.Details = &details
}
return check
}
// generateTestCheck creates a check for a specific spam test
func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check {
check := api.Check{
Category: api.Spam,
Name: fmt.Sprintf("Spam Test: %s", detail.Name),
}
if detail.Score > 0 {
// Negative indicator (increases spam score)
if detail.Score > 2.0 {
check.Status = api.CheckStatusFail
check.Severity = api.PtrTo(api.CheckSeverityHigh)
} else {
check.Status = api.CheckStatusWarn
check.Severity = api.PtrTo(api.CheckSeverityMedium)
}
check.Score = 0.0
check.Grade = ScoreToCheckGrade(0)
check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score)
check.Advice = &advice
} else {
// Positive indicator (decreases spam score)
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Grade = ScoreToCheckGrade((1.0 / 20.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score)
advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score)
check.Advice = &advice
}
check.Details = &detail.Description
return check
}

View file

@ -26,8 +26,6 @@ import (
"net/mail"
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseSpamStatus(t *testing.T) {
@ -298,86 +296,6 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
}
}
func TestGenerateSpamAssassinChecks(t *testing.T) {
tests := []struct {
name string
result *SpamAssassinResult
expectedStatus api.CheckStatus
minChecks int
}{
{
name: "Nil result",
result: nil,
expectedStatus: api.CheckStatusWarn,
minChecks: 1,
},
{
name: "Clean email",
result: &SpamAssassinResult{
IsSpam: false,
Score: -0.5,
RequiredScore: 5.0,
Tests: []string{"ALL_TRUSTED"},
TestDetails: map[string]SpamTestDetail{
"ALL_TRUSTED": {
Name: "ALL_TRUSTED",
Score: -1.5,
Description: "All mail servers are trusted",
},
},
},
expectedStatus: api.CheckStatusPass,
minChecks: 2, // Main check + one test detail
},
{
name: "Spam email",
result: &SpamAssassinResult{
IsSpam: true,
Score: 15.0,
RequiredScore: 5.0,
Tests: []string{"BAYES_99", "SPOOFED_SENDER"},
TestDetails: map[string]SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
Description: "Bayes spam probability is 99 to 100%",
},
"SPOOFED_SENDER": {
Name: "SPOOFED_SENDER",
Score: 3.5,
Description: "From address doesn't match envelope sender",
},
},
},
expectedStatus: api.CheckStatusFail,
minChecks: 3, // Main check + two significant tests
},
}
analyzer := NewSpamAssassinAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateSpamAssassinChecks(tt.result)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Check main check (first one)
if len(checks) > 0 {
mainCheck := checks[0]
if mainCheck.Status != tt.expectedStatus {
t.Errorf("Main check status = %v, want %v", mainCheck.Status, tt.expectedStatus)
}
if mainCheck.Category != api.Spam {
t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
}
}
})
}
}
func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
email := &EmailMessage{
@ -391,98 +309,6 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
}
}
func TestGenerateMainSpamCheck(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
tests := []struct {
name string
score float64
required float64
expectedStatus api.CheckStatus
}{
{"Excellent", -1.0, 5.0, api.CheckStatusPass},
{"Good", 2.0, 5.0, api.CheckStatusPass},
{"Borderline", 6.0, 5.0, api.CheckStatusWarn},
{"High", 8.0, 5.0, api.CheckStatusWarn},
{"Very High", 15.0, 5.0, api.CheckStatusFail},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &SpamAssassinResult{
Score: tt.score,
RequiredScore: tt.required,
}
check := analyzer.generateMainSpamCheck(result)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Spam {
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
}
if !strings.Contains(check.Message, "spam score") {
t.Error("Message should contain 'spam score'")
}
})
}
}
func TestGenerateTestCheck(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
tests := []struct {
name string
detail SpamTestDetail
expectedStatus api.CheckStatus
}{
{
name: "High penalty test",
detail: SpamTestDetail{
Name: "BAYES_99",
Score: 5.0,
Description: "Bayes spam probability is 99 to 100%",
},
expectedStatus: api.CheckStatusFail,
},
{
name: "Medium penalty test",
detail: SpamTestDetail{
Name: "HTML_MESSAGE",
Score: 1.5,
Description: "Contains HTML",
},
expectedStatus: api.CheckStatusWarn,
},
{
name: "Positive test",
detail: SpamTestDetail{
Name: "ALL_TRUSTED",
Score: -2.0,
Description: "All mail servers are trusted",
},
expectedStatus: api.CheckStatusPass,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateTestCheck(tt.detail)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Spam {
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
}
if !strings.Contains(check.Name, tt.detail.Name) {
t.Errorf("Check name should contain test name %s", tt.detail.Name)
}
})
}
}
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,
@ -623,34 +449,6 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
if score != 100 {
t.Errorf("GetSpamAssassinScore() = %v, want 100 (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 != 100 {
t.Errorf("Main check score = %v, want 100", 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: %d, status: %s)",
i+1, check.Name, check.Message, check.Score, check.Status)
}
}
// Helper function to compare string slices

View file

@ -0,0 +1,210 @@
<script lang="ts">
import type { Authentication, ReportSummary } from "$lib/api/types.gen";
interface Props {
authentication: Authentication;
authenticationScore?: number;
}
let { authentication, authenticationScore }: Props = $props();
function getAuthResultClass(result: string): string {
switch (result) {
case "pass":
return "text-success";
case "fail":
case "missing":
return "text-danger";
case "softfail":
case "neutral":
return "text-warning";
default:
return "text-muted";
}
}
function getAuthResultIcon(result: string): string {
switch (result) {
case "pass":
return "bi-check-circle-fill";
case "fail":
return "bi-x-circle-fill";
case "softfail":
case "neutral":
return "bi-exclamation-circle-fill";
case "missing":
return "bi-dash-circle-fill";
default:
return "bi-question-circle";
}
}
function getAuthResultText(result: string): string {
switch (result) {
case "missing":
return "Not configured";
default:
return result;
}
}
</script>
<div class="card shadow-sm">
<div class="card-header bg-white">
<h4 class="mb-0 d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-shield-check me-2"></i>
Authentication
</span>
{#if authenticationScore !== undefined}
<span class="badge bg-secondary">
{authenticationScore}%
</span>
{/if}
</h4>
</div>
<div class="card-body">
<div class="row row-cols-1">
<!-- SPF (Required) -->
<div class="col mb-3">
<div class="d-flex align-items-start">
{#if authentication.spf}
<i class="bi {getAuthResultIcon(authentication.spf.result)} {getAuthResultClass(authentication.spf.result)} me-2 fs-5"></i>
<div>
<strong>SPF</strong>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.spf.result)}">
{authentication.spf.result}
</span>
{#if authentication.spf.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{authentication.spf.domain}</span>
</div>
{/if}
{#if authentication.spf.details}
<pre class="p-2 mb-0 bg-light text-muted small" style="white-space: pre-wrap">{authentication.spf.details}</pre>
{/if}
</div>
{:else}
<i class="bi {getAuthResultIcon('missing')} {getAuthResultClass('missing')} me-2 fs-5"></i>
<div>
<strong>SPF</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing')}">
{getAuthResultText('missing')}
</span>
<div class="text-muted small">SPF record is required for proper email authentication</div>
</div>
{/if}
</div>
</div>
<!-- DKIM (Required) -->
<div class="col mb-3">
<div class="d-flex align-items-start">
{#if authentication.dkim && authentication.dkim.length > 0}
<i class="bi {getAuthResultIcon(authentication.dkim[0].result)} {getAuthResultClass(authentication.dkim[0].result)} me-2 fs-5"></i>
<div>
<strong>DKIM</strong>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.dkim[0].result)}">
{authentication.dkim[0].result}
</span>
{#if authentication.dkim[0].domain}
<div class="text-muted small">{authentication.dkim[0].domain}</div>
{/if}
{#if authentication.dkim[0].selector}
<div class="text-muted small">Selector: {authentication.dkim[0].selector}</div>
{/if}
{#if authentication.dkim.details}
<pre class="p-2 mb-0 bg-light text-muted small" style="white-space: pre-wrap">{authentication.dkim.details}</pre>
{/if}
</div>
{:else}
<i class="bi {getAuthResultIcon('missing')} {getAuthResultClass('missing')} me-2 fs-5"></i>
<div>
<strong>DKIM</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing')}">
{getAuthResultText('missing')}
</span>
<div class="text-muted small">DKIM signature is required for proper email authentication</div>
</div>
{/if}
</div>
</div>
<!-- DMARC (Required) -->
<div class="col mb-3">
<div class="d-flex align-items-start">
{#if authentication.dmarc}
<i class="bi {getAuthResultIcon(authentication.dmarc.result)} {getAuthResultClass(authentication.dmarc.result)} me-2 fs-5"></i>
<div>
<strong>DMARC</strong>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.dmarc.result)}">
{authentication.dmarc.result}
</span>
{#if authentication.dmarc.details}
<pre class="p-2 mb-0 bg-light text-muted small" style="white-space: pre-wrap">{authentication.dmarc.details}</pre>
{/if}
</div>
{:else}
<i class="bi {getAuthResultIcon('missing')} {getAuthResultClass('missing')} me-2 fs-5"></i>
<div>
<strong>DMARC</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing')}">
{getAuthResultText('missing')}
</span>
<div class="text-muted small">DMARC policy is required for proper email authentication</div>
</div>
{/if}
</div>
</div>
<!-- BIMI (Optional) -->
<div class="col mb-3">
<div class="d-flex align-items-start">
{#if authentication.bimi}
<i class="bi {getAuthResultIcon(authentication.bimi.result)} {getAuthResultClass(authentication.bimi.result)} me-2 fs-5"></i>
<div>
<strong>BIMI</strong>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.bimi.result)}">
{authentication.bimi.result}
</span>
{#if authentication.bimi.details}
<pre class="p-2 mb-0 bg-light text-muted small" style="white-space: pre-wrap">{authentication.bimi.details}</pre>
{/if}
</div>
{:else}
<i class="bi bi-info-circle text-muted me-2 fs-5"></i>
<div>
<strong>BIMI</strong>
<span class="text-uppercase ms-2 text-muted">
Optional
</span>
<div class="text-muted small">Brand Indicators for Message Identification (optional enhancement)</div>
</div>
{/if}
</div>
</div>
<!-- ARC (Optional) -->
{#if authentication.arc}
<div class="col mb-3">
<div class="d-flex align-items-start">
<i class="bi {getAuthResultIcon(authentication.arc.result)} {getAuthResultClass(authentication.arc.result)} me-2 fs-5"></i>
<div>
<strong>ARC</strong>
<span class="text-uppercase ms-2 {getAuthResultClass(authentication.arc.result)}">
{authentication.arc.result}
</span>
{#if authentication.arc.chain_length}
<div class="text-muted small">Chain length: {authentication.arc.chain_length}</div>
{/if}
{#if authentication.arc.details}
<pre class="p-2 mb-0 bg-light text-muted small" style="white-space: pre-wrap">{authentication.arc.details}</pre>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import type { RBLCheck } from "$lib/api/types.gen";
interface Props {
blacklists: Record<string, RBLCheck[]>;
blacklistScore?: number;
}
let { blacklists, blacklistScore }: Props = $props();
</script>
<div class="card shadow-sm">
<div class="card-header bg-white">
<h4 class="mb-0 d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-shield-exclamation me-2"></i>
Blacklist Checks
</span>
{#if blacklistScore !== undefined}
<span class="badge bg-secondary">
{blacklistScore}%
</span>
{/if}
</h4>
</div>
<div class="card-body">
<div class="row row-cols-1 row-cols-lg-2">
{#each Object.entries(blacklists) as [ip, checks]}
<div class="col mb-3">
<h6 class="text-muted">
<i class="bi bi-hdd-network me-1"></i>
{ip}
</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>RBL</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each checks as check}
<tr>
<td><code>{check.rbl}</code></td>
<td title={check.response || '-'}>
<span class="badge {check.listed ? 'bg-danger' : check.error ? 'bg-dark' : 'bg-success'}">
{check.error ? 'Error' : (check.listed ? 'Listed' : 'Clean')}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/each}
</div>
</div>
</div>

View file

@ -1,71 +0,0 @@
<script lang="ts">
import type { Check } from "$lib/api/types.gen";
interface Props {
check: Check;
}
let { check }: Props = $props();
function getCheckIcon(status: string): string {
switch (status) {
case "pass":
return "bi-check-circle-fill text-success";
case "fail":
return "bi-x-circle-fill text-danger";
case "warn":
return "bi-exclamation-triangle-fill text-warning";
case "info":
return "bi-info-circle-fill text-info";
default:
return "bi-question-circle-fill text-secondary";
}
}
</script>
<div class="card mb-3">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="fs-4">
<i class={getCheckIcon(check.status)}></i>
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<h5 class="fw-bold mb-1">{check.name}</h5>
<span class="badge bg-light text-dark">{check.score}%</span>
</div>
<p class="mt-2 mb-2">{check.message}</p>
{#if check.advice}
<div class="alert alert-light border mb-2" role="alert">
<i class="bi bi-lightbulb me-2"></i>
<strong>Recommendation:</strong>
{check.advice}
</div>
{/if}
{#if check.details}
<details class="small text-muted">
<summary class="cursor-pointer">Technical Details</summary>
<pre class="mt-2 mb-0 small bg-light p-2 rounded" style="white-space: pre-wrap;">{check.details}</pre>
</details>
{/if}
</div>
</div>
</div>
</div>
<style>
.cursor-pointer {
cursor: pointer;
}
details summary {
user-select: none;
}
details summary:hover {
color: var(--bs-primary);
}
</style>

View file

@ -0,0 +1,165 @@
<script lang="ts">
import type { ContentAnalysis } from "$lib/api/types.gen";
interface Props {
contentAnalysis: ContentAnalysis;
contentScore?: number;
}
let { contentAnalysis, contentScore }: Props = $props();
</script>
<div class="card shadow-sm">
<div class="card-header bg-white">
<h4 class="mb-0 d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-file-text me-2"></i>
Content Analysis
</span>
{#if contentScore !== undefined}
<span class="badge bg-secondary">
{contentScore}%
</span>
{/if}
</h4>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bi {contentAnalysis.has_html ? 'bi-check-circle text-success' : 'bi-x-circle text-muted'} me-2"></i>
<span>HTML Part</span>
</div>
<div class="d-flex align-items-center mb-2">
<i class="bi {contentAnalysis.has_plaintext ? 'bi-check-circle text-success' : 'bi-x-circle text-muted'} me-2"></i>
<span>Plaintext Part</span>
</div>
{#if typeof contentAnalysis.has_unsubscribe_link === 'boolean'}
<div class="d-flex align-items-center mb-2">
<i class="bi {contentAnalysis.has_unsubscribe_link ? 'bi-check-circle text-success' : 'bi-x-circle text-warning'} me-2"></i>
<span>Unsubscribe Link</span>
</div>
{/if}
</div>
<div class="col-md-6">
{#if contentAnalysis.text_to_image_ratio !== undefined}
<div class="mb-2">
<strong>Text to Image Ratio:</strong>
<span class="ms-2">{contentAnalysis.text_to_image_ratio.toFixed(2)}</span>
</div>
{/if}
{#if contentAnalysis.unsubscribe_methods && contentAnalysis.unsubscribe_methods.length > 0}
<div class="mb-2">
<strong>Unsubscribe Methods:</strong>
<div class="mt-1">
{#each contentAnalysis.unsubscribe_methods as method}
<span class="badge bg-info me-1">{method}</span>
{/each}
</div>
</div>
{/if}
</div>
</div>
{#if contentAnalysis.html_issues && contentAnalysis.html_issues.length > 0}
<div class="mt-3">
<h6>Content Issues</h6>
{#each contentAnalysis.html_issues as issue}
<div class="alert alert-{issue.severity === 'critical' || issue.severity === 'high' ? 'danger' : issue.severity === 'medium' ? 'warning' : 'info'} py-2 px-3 mb-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>{issue.type}</strong>
<div class="small">{issue.message}</div>
{#if issue.location}
<div class="small text-muted">{issue.location}</div>
{/if}
{#if issue.advice}
<div class="small mt-1">
<i class="bi bi-lightbulb me-1"></i>
{issue.advice}
</div>
{/if}
</div>
<span class="badge bg-secondary">{issue.severity}</span>
</div>
</div>
{/each}
</div>
{/if}
{#if contentAnalysis.links && contentAnalysis.links.length > 0}
<div class="mt-3">
<h6>Links ({contentAnalysis.links.length})</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>URL</th>
<th>Status</th>
<th>HTTP Code</th>
</tr>
</thead>
<tbody>
{#each contentAnalysis.links as link}
<tr>
<td>
<small class="text-break">{link.url}</small>
{#if link.is_shortened}
<span class="badge bg-warning ms-1">Shortened</span>
{/if}
</td>
<td>
<span class="badge {link.status === 'valid' ? 'bg-success' : link.status === 'broken' ? 'bg-danger' : 'bg-warning'}">
{link.status}
</span>
</td>
<td>{link.http_code || '-'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{#if contentAnalysis.images && contentAnalysis.images.length > 0}
<div class="mt-3">
<h6>Images ({contentAnalysis.images.length})</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Source</th>
<th>Alt Text</th>
<th>Tracking</th>
</tr>
</thead>
<tbody>
{#each contentAnalysis.images as image}
<tr>
<td><small class="text-break">{image.src || '-'}</small></td>
<td>
{#if image.has_alt}
<i class="bi bi-check-circle text-success me-1"></i>
<small>{image.alt_text || 'Present'}</small>
{:else}
<i class="bi bi-x-circle text-warning me-1"></i>
<small class="text-muted">Missing</small>
{/if}
</td>
<td>
{#if image.is_tracking_pixel}
<span class="badge bg-info">Tracking Pixel</span>
{:else}
-
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,46 @@
<script lang="ts">
import type { DNSRecord } from "$lib/api/types.gen";
interface Props {
dnsRecords: DNSRecord[];
}
let { dnsRecords }: Props = $props();
</script>
<div class="card shadow-sm">
<div class="card-header bg-white">
<h4 class="mb-0">
<i class="bi bi-diagram-3 me-2"></i>
DNS Records
</h4>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Domain</th>
<th>Type</th>
<th>Status</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{#each dnsRecords as record}
<tr>
<td><code>{record.domain}</code></td>
<td><span class="badge bg-secondary">{record.record_type}</span></td>
<td>
<span class="badge {record.status === 'found' ? 'bg-success' : record.status === 'missing' ? 'bg-danger' : 'bg-warning'}">
{record.status}
</span>
</td>
<td><small class="text-muted">{record.value || '-'}</small></td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>

View file

@ -0,0 +1,169 @@
<script lang="ts">
import type { HeaderAnalysis } from "$lib/api/types.gen";
interface Props {
headerAnalysis: HeaderAnalysis;
headerScore?: number;
}
let { headerAnalysis, headerScore }: Props = $props();
</script>
<div class="card shadow-sm">
<div class="card-header bg-white">
<h4 class="mb-0 d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-list-ul me-2"></i>
Header Analysis
</span>
{#if headerScore !== undefined}
<span class="badge bg-secondary">
{headerScore}%
</span>
{/if}
</h4>
</div>
<div class="card-body">
{#if headerAnalysis.issues && headerAnalysis.issues.length > 0}
<div class="mb-3">
<h6>Issues</h6>
{#each headerAnalysis.issues as issue}
<div class="alert alert-{issue.severity === 'critical' || issue.severity === 'high' ? 'danger' : issue.severity === 'medium' ? 'warning' : 'info'} py-2 px-3 mb-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>{issue.header}</strong>
<div class="small">{issue.message}</div>
{#if issue.advice}
<div class="small mt-1">
<i class="bi bi-lightbulb me-1"></i>
{issue.advice}
</div>
{/if}
</div>
<span class="badge bg-secondary">{issue.severity}</span>
</div>
</div>
{/each}
</div>
{/if}
{#if headerAnalysis.domain_alignment}
<div class="mb-3">
<h6>Domain Alignment</h6>
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-4">
<small class="text-muted">From Domain</small>
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
</div>
<div class="col-md-4">
<small class="text-muted">Return-Path Domain</small>
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
</div>
<div class="col-md-4">
<small class="text-muted">Aligned</small>
<div>
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'} me-1"></i>
{headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'}
</div>
</div>
</div>
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
<div class="mt-2">
{#each headerAnalysis.domain_alignment.issues as issue}
<div class="text-warning small">
<i class="bi bi-exclamation-triangle me-1"></i>
{issue}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
{#if headerAnalysis.headers && Object.keys(headerAnalysis.headers).length > 0}
<div class="mt-3">
<h6>Headers</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Header</th>
<th>Present</th>
<th>Valid</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{#each Object.entries(headerAnalysis.headers) as [name, check]}
<tr>
<td>
<code>{name}</code>
{#if check.importance}
<span class="badge bg-{check.importance === 'required' ? 'danger' : check.importance === 'recommended' ? 'warning' : 'secondary'} ms-1">
{check.importance}
</span>
{/if}
</td>
<td>
<i class="bi {check.present ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'}"></i>
</td>
<td>
{#if check.valid !== undefined}
<i class="bi {check.valid ? 'bi-check-circle text-success' : 'bi-x-circle text-warning'}"></i>
{:else}
-
{/if}
</td>
<td>
<small class="text-muted text-break">{check.value || '-'}</small>
{#if check.issues && check.issues.length > 0}
{#each check.issues as issue}
<div class="text-warning small">
<i class="bi bi-exclamation-triangle me-1"></i>
{issue}
</div>
{/each}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{#if headerAnalysis.received_chain && headerAnalysis.received_chain.length > 0}
<div class="mt-3">
<h6>Email Path (Received Chain)</h6>
<div class="list-group">
{#each headerAnalysis.received_chain as hop, i}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">
<span class="badge bg-primary me-2">{i + 1}</span>
{hop.from || 'Unknown'}{hop.by || 'Unknown'}
</h6>
<small class="text-muted">{hop.timestamp || '-'}</small>
</div>
{#if hop.with || hop.id}
<p class="mb-1 small">
{#if hop.with}
<span class="text-muted">Protocol:</span> <code>{hop.with}</code>
{/if}
{#if hop.id}
<span class="text-muted ms-3">ID:</span> <code>{hop.id}</code>
{/if}
</p>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>

View file

@ -50,19 +50,6 @@
<small class="text-muted d-block">Authentication</small>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center">
<strong
class="fs-2"
class:text-success={summary.spam_score >= 100}
class:text-warning={summary.spam_score < 100 && summary.spam_score >= 50}
class:text-danger={summary.spam_score < 50}
>
{summary.spam_score}%
</strong>
<small class="text-muted d-block">Spam Score</small>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center">
<strong
@ -77,20 +64,6 @@
<small class="text-muted d-block">Blacklists</small>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center">
<strong
class="fs-2"
class:text-success={summary.content_score >= 100}
class:text-warning={summary.content_score < 100 &&
summary.content_score >= 50}
class:text-danger={summary.content_score < 50}
>
{summary.content_score}%
</strong>
<small class="text-muted d-block">Content</small>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center">
<strong
@ -105,6 +78,33 @@
<small class="text-muted d-block">Headers</small>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center">
<strong
class="fs-2"
class:text-success={summary.spam_score >= 100}
class:text-warning={summary.spam_score < 100 && summary.spam_score >= 50}
class:text-danger={summary.spam_score < 50}
>
{summary.spam_score}%
</strong>
<small class="text-muted d-block">Spam Score</small>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center">
<strong
class="fs-2"
class:text-success={summary.content_score >= 100}
class:text-warning={summary.content_score < 100 &&
summary.content_score >= 50}
class:text-danger={summary.content_score < 50}
>
{summary.content_score}%
</strong>
<small class="text-muted d-block">Content</small>
</div>
</div>
</div>
{/if}
</div>

View file

@ -2,7 +2,11 @@
export { default as FeatureCard } from "./FeatureCard.svelte";
export { default as HowItWorksStep } from "./HowItWorksStep.svelte";
export { default as ScoreCard } from "./ScoreCard.svelte";
export { default as CheckCard } from "./CheckCard.svelte";
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
export { default as PendingState } from "./PendingState.svelte";
export { default as AuthenticationCard } from "./AuthenticationCard.svelte";
export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
export { default as BlacklistCard } from "./BlacklistCard.svelte";
export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte";
export { default as HeaderAnalysisCard } from "./HeaderAnalysisCard.svelte";

View file

@ -3,7 +3,16 @@
import { page } from "$app/state";
import { getTest, getReport, reanalyzeReport } from "$lib/api";
import type { Test, Report } from "$lib/api/types.gen";
import { ScoreCard, CheckCard, SpamAssassinCard, PendingState } from "$lib/components";
import {
ScoreCard,
SpamAssassinCard,
PendingState,
AuthenticationCard,
DnsRecordsCard,
BlacklistCard,
ContentAnalysisCard,
HeaderAnalysisCard
} from "$lib/components";
let testId = $derived(page.params.test);
let test = $state<Test | null>(null);
@ -15,20 +24,6 @@
let nextfetch = $state(23);
let nbfetch = $state(0);
// Group checks by category
let groupedChecks = $derived(() => {
if (!report) return { };
const groups: Record<string, typeof report.checks> = { };
for (const check of report.checks) {
if (!groups[check.category]) {
groups[check.category] = [];
}
groups[check.category].push(check);
}
return groups;
});
async function fetchTest() {
if (nbfetch > 0) {
nextfetch = Math.max(nextfetch, Math.floor(3 + nbfetch * 0.5));
@ -86,29 +81,6 @@
stopPolling();
});
function getCategoryIcon(category: string): string {
switch (category) {
case "authentication":
return "bi-shield-check";
case "dns":
return "bi-diagram-3";
case "content":
return "bi-file-text";
case "blacklist":
return "bi-shield-exclamation";
case "headers":
return "bi-list-ul";
case "spam":
return "bi-filter";
default:
return "bi-question-circle";
}
}
function getCategoryScore(checks: typeof report.checks): number {
return Math.round(checks.reduce((sum, check) => sum + check.score, 0) / checks.filter((c) => c.status != "info").length);
}
function getScoreColorClass(percentage: number): string {
if (percentage >= 80) return "text-success";
if (percentage >= 50) return "text-warning";
@ -166,45 +138,78 @@
<!-- Results State -->
<div class="fade-in">
<!-- Score Header -->
<div class="row mb-4">
<div class="row mb-4" id="score">
<div class="col-12">
<ScoreCard grade={report.grade} score={report.score} summary={report.summary} />
</div>
</div>
<!-- Detailed Checks -->
<div class="row mb-4">
<div class="col-12">
<h3 class="fw-bold mb-3">Detailed Checks</h3>
{#each Object.entries(groupedChecks()) as [category, checks]}
{@const categoryScore = getCategoryScore(checks)}
<div class="category-section mb-4">
<h4 class="category-title text-capitalize mb-3 d-flex justify-content-between align-items-center">
<span>
<i class="bi {getCategoryIcon(category)} me-2"></i>
{category}
</span>
<span class="category-score {getScoreColorClass(categoryScore)}">
{categoryScore}%
</span>
</h4>
{#each checks as check}
<CheckCard {check} />
{/each}
</div>
{/each}
<!-- DNS Records -->
{#if report.dns_records && report.dns_records.length > 0}
<div class="row mb-4" id="dns">
<div class="col-12">
<DnsRecordsCard dnsRecords={report.dns_records} />
</div>
</div>
</div>
{/if}
<!-- Authentication Results -->
{#if report.authentication}
<div class="row mb-4" id="authentication">
<div class="col-12">
<AuthenticationCard
authentication={report.authentication}
authenticationScore={report.summary?.authentication_score}
/>
</div>
</div>
{/if}
<!-- Blacklist Checks -->
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
<div class="row mb-4" id="blacklist">
<div class="col-12">
<BlacklistCard
blacklists={report.blacklists}
blacklistScore={report.summary?.blacklist_score}
/>
</div>
</div>
{/if}
<!-- Header Analysis -->
{#if report.header_analysis}
<div class="row mb-4" id="header">
<div class="col-12">
<HeaderAnalysisCard
headerAnalysis={report.header_analysis}
headerScore={report.summary?.header_score}
/>
</div>
</div>
{/if}
<!-- Additional Information -->
{#if report.spamassassin}
<div class="row mb-4">
<div class="row mb-4" id="spam">
<div class="col-12">
<SpamAssassinCard spamassassin={report.spamassassin} />
</div>
</div>
{/if}
<!-- Content Analysis -->
{#if report.content_analysis}
<div class="row mb-4" id="content">
<div class="col-12">
<ContentAnalysisCard
contentAnalysis={report.content_analysis}
contentScore={report.summary?.content_score}
/>
</div>
</div>
{/if}
<!-- Action Buttons -->
<div class="row">
<div class="col-12 text-center">