chore(deps): update module golang.org/x/net to v0.51.0 [security] #74

Open
renovate-bot wants to merge 3 commits from renovate/go-golang.org-x-net-vulnerability into master
17 changed files with 236 additions and 124 deletions

View file

@ -350,6 +350,19 @@ components:
listed: false
- rbl: "bl.spamcop.net"
listed: false
whitelists:
type: object
additionalProperties:
type: array
items:
$ref: '#/components/schemas/BlacklistCheck'
description: Map of IP addresses to their DNS whitelist check results (informational only)
example:
"192.0.2.1":
- rbl: "list.dnswl.org"
listed: false
- rbl: "swl.spamhaus.org"
listed: false
content_analysis:
$ref: '#/components/schemas/ContentAnalysis'
header_analysis:
@ -776,7 +789,7 @@ components:
properties:
result:
type: string
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass]
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped]
description: Authentication result
example: "pass"
domain:

7
go.mod
View file

@ -1,22 +1,20 @@
module git.happydns.org/happyDeliver
go 1.24.6
go 1.25.0
require (
github.com/JGLTechnologies/gin-rate-limit v1.5.6
github.com/emersion/go-smtp v0.24.0
github.com/getkin/kin-openapi v0.133.0
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
github.com/oapi-codegen/runtime v1.1.2
golang.org/x/net v0.50.0
golang.org/x/net v0.51.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@ -26,6 +24,7 @@ require (
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect

10
go.sum
View file

@ -1,9 +1,5 @@
github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI=
github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@ -102,7 +98,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -170,7 +165,6 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -213,8 +207,8 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View file

@ -65,6 +65,7 @@ type AnalysisConfig struct {
DNSTimeout time.Duration
HTTPTimeout time.Duration
RBLs []string
DNSWLs []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one
}
@ -88,6 +89,7 @@ func DefaultConfig() *Config {
DNSTimeout: 5 * time.Second,
HTTPTimeout: 10 * time.Second,
RBLs: []string{},
DNSWLs: []string{},
CheckAllIPs: false, // By default, only check the first IP
},
}

View file

@ -44,6 +44,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
cfg.Analysis.DNSTimeout,
cfg.Analysis.HTTPTimeout,
cfg.Analysis.RBLs,
cfg.Analysis.DNSWLs,
cfg.Analysis.CheckAllIPs,
)
@ -130,12 +131,12 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int
// Calculate score using the existing function
// Create a minimal RBLResults structure for scoring
results := &RBLResults{
results := &DNSListResults{
Checks: map[string][]api.BlacklistCheck{ip: checks},
IPsChecked: []string{ip},
ListedCount: listedCount,
}
score, grade := a.analyzer.generator.rblChecker.CalculateRBLScore(results)
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results)
return checks, listedCount, score, grade, nil
}

View file

@ -32,13 +32,15 @@ import (
"git.happydns.org/happyDeliver/internal/api"
)
// RBLChecker checks IP addresses against DNS-based blacklists
type RBLChecker struct {
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
type DNSListChecker struct {
Timeout time.Duration
RBLs []string
Lists []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one
filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
resolver *net.Resolver
informationalSet map[string]bool
informationalSet map[string]bool // Lists whose hits don't count toward the score
}
// DefaultRBLs is a list of commonly used RBL providers
@ -68,10 +70,16 @@ var DefaultInformationalRBLs = []string{
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring
}
// DefaultDNSWLs is a list of commonly used DNSWL providers
var DefaultDNSWLs = []string{
"list.dnswl.org", // DNSWL.org — the main DNS whitelist
"swl.spamhaus.org", // Spamhaus Safe Whitelist
}
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLChecker {
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
if timeout == 0 {
timeout = 5 * time.Second // Default timeout
timeout = 5 * time.Second
}
if len(rbls) == 0 {
rbls = DefaultRBLs
@ -80,30 +88,48 @@ func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLC
for _, rbl := range DefaultInformationalRBLs {
informationalSet[rbl] = true
}
return &RBLChecker{
return &DNSListChecker{
Timeout: timeout,
RBLs: rbls,
Lists: rbls,
CheckAllIPs: checkAllIPs,
filterErrorCodes: true,
resolver: &net.Resolver{PreferGo: true},
informationalSet: informationalSet,
}
}
// RBLResults represents the results of RBL checks
type RBLResults struct {
Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP
IPsChecked []string
ListedCount int // Total listings including informational RBLs
RelevantListedCount int // Listings on scoring (non-informational) RBLs only
// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list
func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker {
if timeout == 0 {
timeout = 5 * time.Second
}
if len(dnswls) == 0 {
dnswls = DefaultDNSWLs
}
return &DNSListChecker{
Timeout: timeout,
Lists: dnswls,
CheckAllIPs: checkAllIPs,
filterErrorCodes: false,
resolver: &net.Resolver{PreferGo: true},
informationalSet: make(map[string]bool),
}
}
// CheckEmail checks all IPs found in the email headers against RBLs
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
results := &RBLResults{
// DNSListResults represents the results of DNS list checks
type DNSListResults struct {
Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP
IPsChecked []string
ListedCount int // Total listings including informational entries
RelevantListedCount int // Listings on scoring (non-informational) lists only
}
// CheckEmail checks all IPs found in the email headers against the configured lists
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
results := &DNSListResults{
Checks: make(map[string][]api.BlacklistCheck),
}
// Extract IPs from Received headers
ips := r.extractIPs(email)
if len(ips) == 0 {
return results
@ -111,20 +137,18 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
results.IPsChecked = ips
// Check each IP against all RBLs
for _, ip := range ips {
for _, rbl := range r.RBLs {
check := r.checkIP(ip, rbl)
for _, list := range r.Lists {
check := r.checkIP(ip, list)
results.Checks[ip] = append(results.Checks[ip], check)
if check.Listed {
results.ListedCount++
if !r.informationalSet[rbl] {
if !r.informationalSet[list] {
results.RelevantListedCount++
}
}
}
// Only check the first IP unless CheckAllIPs is enabled
if !r.CheckAllIPs {
break
}
@ -133,9 +157,8 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
return results
}
// CheckIP checks a single IP address against all configured RBLs
func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
// Validate that it's a valid IP address
// CheckIP checks a single IP address against all configured lists
func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
if !r.isPublicIP(ip) {
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
}
@ -143,9 +166,8 @@ func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
var checks []api.BlacklistCheck
listedCount := 0
// Check the IP against all RBLs
for _, rbl := range r.RBLs {
check := r.checkIP(ip, rbl)
for _, list := range r.Lists {
check := r.checkIP(ip, list)
checks = append(checks, check)
if check.Listed {
listedCount++
@ -156,27 +178,19 @@ func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
}
// extractIPs extracts IP addresses from Received headers
func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
var ips []string
seenIPs := make(map[string]bool)
// Get all Received headers
receivedHeaders := email.Header["Received"]
// Regex patterns for IP addresses
// Match IPv4: xxx.xxx.xxx.xxx
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
// Look for IPs in Received headers
for _, received := range receivedHeaders {
// Find all IPv4 addresses
matches := ipv4Pattern.FindAllString(received, -1)
for _, match := range matches {
// Skip private/reserved IPs
if !r.isPublicIP(match) {
continue
}
// Avoid duplicates
if !seenIPs[match] {
ips = append(ips, match)
seenIPs[match] = true
@ -184,13 +198,10 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
}
}
// If no IPs found in Received headers, try X-Originating-IP
if len(ips) == 0 {
originatingIP := email.Header.Get("X-Originating-IP")
if originatingIP != "" {
// Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
// Remove any whitespace
cleanIP = strings.TrimSpace(cleanIP)
matches := ipv4Pattern.FindString(cleanIP)
if matches != "" && r.isPublicIP(matches) {
@ -203,19 +214,16 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
}
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
func (r *RBLChecker) isPublicIP(ipStr string) bool {
func (r *DNSListChecker) isPublicIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
// Check if it's a private network
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
// Additional checks for reserved ranges
// 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
if ip.IsUnspecified() {
return false
}
@ -223,51 +231,43 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool {
return true
}
// checkIP checks a single IP against a single RBL
func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
// checkIP checks a single IP against a single DNS list
func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
check := api.BlacklistCheck{
Rbl: rbl,
Rbl: list,
}
// Reverse the IP for DNSBL query
reversedIP := r.reverseIP(ip)
if reversedIP == "" {
check.Error = api.PtrTo("Failed to reverse IP address")
return check
}
// Construct DNSBL query: reversed-ip.rbl-domain
query := fmt.Sprintf("%s.%s", reversedIP, rbl)
query := fmt.Sprintf("%s.%s", reversedIP, list)
// Perform DNS lookup with timeout
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
defer cancel()
addrs, err := r.resolver.LookupHost(ctx, query)
if err != nil {
// Most likely not listed (NXDOMAIN)
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsNotFound {
check.Listed = false
return check
}
}
// Other DNS errors
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 = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2)
check.Response = api.PtrTo(addrs[0])
// 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" {
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
check.Listed = false
check.Error = api.PtrTo(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)", list, addrs[0]))
} else {
// Normal listing response
check.Listed = true
}
}
@ -275,50 +275,47 @@ func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
return check
}
// reverseIP reverses an IPv4 address for DNSBL queries
// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
// Example: 192.0.2.1 -> 1.2.0.192
func (r *RBLChecker) reverseIP(ipStr string) string {
func (r *DNSListChecker) reverseIP(ipStr string) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ""
}
// Convert to IPv4
ipv4 := ip.To4()
if ipv4 == nil {
return "" // IPv6 not supported yet
}
// Reverse the octets
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
}
// CalculateRBLScore calculates the blacklist contribution to deliverability.
// Informational RBLs are not counted in the score.
func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) {
// CalculateScore calculates the list contribution to deliverability.
// Informational lists are not counted in the score.
func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) {
if results == nil || len(results.IPsChecked) == 0 {
// No IPs to check, give benefit of doubt
return 100, ""
}
scoringRBLCount := len(r.RBLs) - len(r.informationalSet)
if scoringRBLCount <= 0 {
scoringListCount := len(r.Lists) - len(r.informationalSet)
if scoringListCount <= 0 {
return 100, "A+"
}
percentage := 100 - results.RelevantListedCount*100/scoringRBLCount
percentage := 100 - results.RelevantListedCount*100/scoringListCount
return percentage, ScoreToGrade(percentage)
}
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
var listedIPs []string
for ip, rblChecks := range results.Checks {
for _, check := range rblChecks {
for ip, checks := range results.Checks {
for _, check := range checks {
if check.Listed {
listedIPs = append(listedIPs, ip)
break // Only add the IP once
break
}
}
}
@ -326,17 +323,17 @@ func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
return listedIPs
}
// GetRBLsForIP returns all RBLs that list a specific IP
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
var rbls []string
// GetListsForIP returns all lists that match a specific IP
func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
var lists []string
if rblChecks, exists := results.Checks[ip]; exists {
for _, check := range rblChecks {
if checks, exists := results.Checks[ip]; exists {
for _, check := range checks {
if check.Listed {
rbls = append(rbls, check.Rbl)
lists = append(lists, check.Rbl)
}
}
}
return rbls
return lists
}

View file

@ -59,8 +59,8 @@ func TestNewRBLChecker(t *testing.T) {
if checker.Timeout != tt.expectedTimeout {
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
}
if len(checker.RBLs) != tt.expectedRBLs {
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
if len(checker.Lists) != tt.expectedRBLs {
t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs)
}
if checker.resolver == nil {
t.Error("Resolver should not be nil")
@ -326,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score, _ := checker.CalculateRBLScore(tt.results)
score, _ := checker.CalculateScore(tt.results)
if score != tt.expectedScore {
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
}
@ -402,7 +402,7 @@ func TestGetRBLsForIP(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rbls := checker.GetRBLsForIP(results, tt.ip)
rbls := checker.GetListsForIP(results, tt.ip)
if len(rbls) != len(tt.expectedRBLs) {
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))

View file

@ -35,7 +35,8 @@ type ReportGenerator struct {
spamAnalyzer *SpamAssassinAnalyzer
rspamdAnalyzer *RspamdAnalyzer
dnsAnalyzer *DNSAnalyzer
rblChecker *RBLChecker
rblChecker *DNSListChecker
dnswlChecker *DNSListChecker
contentAnalyzer *ContentAnalyzer
headerAnalyzer *HeaderAnalyzer
}
@ -45,6 +46,7 @@ func NewReportGenerator(
dnsTimeout time.Duration,
httpTimeout time.Duration,
rbls []string,
dnswls []string,
checkAllIPs bool,
) *ReportGenerator {
return &ReportGenerator{
@ -53,6 +55,7 @@ func NewReportGenerator(
rspamdAnalyzer: NewRspamdAnalyzer(),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
contentAnalyzer: NewContentAnalyzer(httpTimeout),
headerAnalyzer: NewHeaderAnalyzer(),
}
@ -65,7 +68,8 @@ type AnalysisResults struct {
Content *ContentResults
DNS *api.DNSResults
Headers *api.HeaderAnalysis
RBL *RBLResults
RBL *DNSListResults
DNSWL *DNSListResults
SpamAssassin *api.SpamAssassinResult
Rspamd *api.RspamdResult
}
@ -81,6 +85,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
results.RBL = r.rblChecker.CheckEmail(email)
results.DNSWL = r.dnswlChecker.CheckEmail(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
results.Content = r.contentAnalyzer.AnalyzeContent(email)
@ -135,7 +140,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
blacklistScore := 0
var blacklistGrade string
if results.RBL != nil {
blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL)
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL)
}
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
@ -197,6 +202,11 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
report.Blacklists = &results.RBL.Checks
}
// Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only)
if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 {
report.Whitelists = &results.DNSWL.Checks
}
// Add SpamAssassin result with individual deliverability score
if results.SpamAssassin != nil {
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)

View file

@ -32,7 +32,7 @@ import (
)
func TestNewReportGenerator(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
if gen == nil {
t.Fatal("Expected report generator, got nil")
}
@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) {
}
func TestAnalyzeEmail(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
email := createTestEmail()
@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) {
}
func TestGenerateReport(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
testID := uuid.New()
email := createTestEmail()
@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) {
}
func TestGenerateReportWithSpamAssassin(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
testID := uuid.New()
email := createTestEmailWithSpamAssassin()
@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
}
func TestGenerateRawEmail(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
tests := []struct {
name string

View file

@ -331,7 +331,7 @@
highlight: { color: "good", bold: true },
link: "#dns-bimi",
});
if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) {
if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) {
segments.push({ text: " declined to participate" });
} else if (bimiResult?.result === "fail") {
segments.push({ text: " but " });

View file

@ -0,0 +1,62 @@
<script lang="ts">
import type { BlacklistCheck } from "$lib/api/types.gen";
import { theme } from "$lib/stores/theme";
interface Props {
whitelists: Record<string, BlacklistCheck[]>;
}
let { whitelists }: Props = $props();
</script>
<div class="card shadow-sm" id="dnswl-details">
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
<h4 class="mb-0 d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-shield-check me-2"></i>
Whitelist Checks
</span>
<span class="badge bg-info text-white">Informational</span>
</h4>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
DNS whitelists identify trusted senders. Being listed here is a positive signal, but has
no impact on the overall score.
</p>
<div class="row row-cols-1 row-cols-lg-2">
{#each Object.entries(whitelists) as [ip, checks]}
<div class="col mb-3">
<h5 class="text-muted">
<i class="bi bi-hdd-network me-1"></i>
{ip}
</h5>
<table class="table table-sm table-striped table-hover mb-0">
<tbody>
{#each checks as check}
<tr>
<td title={check.response || "-"}>
<span
class="badge"
class:bg-success={check.listed}
class:bg-dark={check.error}
class:bg-secondary={!check.listed && !check.error}
>
{check.error
? "Error"
: check.listed
? "Listed"
: "Not listed"}
</span>
</td>
<td><code>{check.rbl}</code></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
</div>
</div>
</div>

View file

@ -24,3 +24,4 @@ export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
export { default as SummaryCard } from "./SummaryCard.svelte";
export { default as TinySurvey } from "./TinySurvey.svelte";
export { default as WhitelistCard } from "./WhitelistCard.svelte";

View file

@ -25,6 +25,7 @@ interface AppConfig {
report_retention?: number;
survey_url?: string;
custom_logo_url?: string;
rbls?: string[];
}
const defaultConfig: AppConfig = {

View file

@ -26,7 +26,7 @@ const getInitialTheme = () => {
if (!browser) return "light";
const stored = localStorage.getItem("theme");
if (stored) return stored;
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
};

View file

@ -28,7 +28,7 @@
});
if (response.response.ok) {
result = response.data;
result = response.data ?? null;
} else if (response.error) {
error = response.error.message || "Failed to check IP address";
}

View file

@ -130,7 +130,7 @@
<div class="d-flex justify-content-end me-lg-5 mt-3">
<TinySurvey
class="bg-primary-subtle rounded-4 p-3 text-center"
source={"rbl-" + result.ip}
source={"domain-" + result.domain}
/>
</div>
</div>

View file

@ -3,7 +3,7 @@
import { onDestroy } from "svelte";
import { getReport, getTest, reanalyzeReport } from "$lib/api";
import type { Report, Test } from "$lib/api/types.gen";
import type { BlacklistCheck, Report, Test } from "$lib/api/types.gen";
import {
AuthenticationCard,
BlacklistCard,
@ -17,8 +17,11 @@
SpamAssassinCard,
SummaryCard,
TinySurvey,
WhitelistCard,
} from "$lib/components";
type BlacklistRecords = Record<string, BlacklistCheck[]>;
let testId = $derived(page.params.test);
let test = $state<Test | null>(null);
let report = $state<Report | null>(null);
@ -321,17 +324,46 @@
{/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}
blacklistGrade={report.summary?.blacklist_grade}
blacklistScore={report.summary?.blacklist_score}
receivedChain={report.header_analysis?.received_chain}
/>
{#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)}
<BlacklistCard
{blacklists}
blacklistGrade={report.summary?.blacklist_grade}
blacklistScore={report.summary?.blacklist_score}
receivedChain={report.header_analysis?.received_chain}
/>
{/snippet}
<!-- Whitelist Checks -->
{#snippet whitelistChecks(whitelists: BlacklistRecords)}
<WhitelistCard {whitelists} />
{/snippet}
<!-- Blacklist & Whitelist Checks -->
{#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1}
<div class="row mb-4">
<div class="col-6" id="blacklist">
{@render blacklistChecks(report.blacklists, report)}
</div>
<div class="col-6" id="whitelist">
{@render whitelistChecks(report.whitelists)}
</div>
</div>
{:else}
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
<div class="row mb-4" id="blacklist">
<div class="col-12">
{@render blacklistChecks(report.blacklists, report)}
</div>
</div>
{/if}
{#if report.whitelists && Object.keys(report.whitelists).length > 0}
<div class="row mb-4" id="whitelist">
<div class="col-12">
{@render whitelistChecks(report.whitelists)}
</div>
</div>
{/if}
{/if}
<!-- Header Analysis -->