From 8f53e5a8a524405e7a5f101226a627bfe6ec6be3 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 15 Oct 2025 15:24:43 +0700 Subject: [PATCH] Implement RBL (DNS blacklist) checker --- internal/analyzer/rbl.go | 408 ++++++++++++++++++++++ internal/analyzer/rbl_test.go | 629 ++++++++++++++++++++++++++++++++++ 2 files changed, 1037 insertions(+) create mode 100644 internal/analyzer/rbl.go create mode 100644 internal/analyzer/rbl_test.go diff --git a/internal/analyzer/rbl.go b/internal/analyzer/rbl.go new file mode 100644 index 0000000..be7366c --- /dev/null +++ b/internal/analyzer/rbl.go @@ -0,0 +1,408 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "context" + "fmt" + "net" + "regexp" + "strings" + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +// RBLChecker checks IP addresses against DNS-based blacklists +type RBLChecker struct { + Timeout time.Duration + RBLs []string + resolver *net.Resolver +} + +// DefaultRBLs is a list of commonly used RBL providers +var DefaultRBLs = []string{ + "zen.spamhaus.org", // Spamhaus combined list + "bl.spamcop.net", // SpamCop + "dnsbl.sorbs.net", // SORBS + "b.barracudacentral.org", // Barracuda + "cbl.abuseat.org", // CBL (Composite Blocking List) + "dnsbl-1.uceprotect.net", // UCEPROTECT Level 1 +} + +// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list +func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker { + if timeout == 0 { + timeout = 5 * time.Second // Default timeout + } + if len(rbls) == 0 { + rbls = DefaultRBLs + } + return &RBLChecker{ + Timeout: timeout, + RBLs: rbls, + resolver: &net.Resolver{ + PreferGo: true, + }, + } +} + +// RBLResults represents the results of RBL checks +type RBLResults struct { + Checks []RBLCheck + IPsChecked []string + ListedCount int +} + +// RBLCheck represents a single RBL check result +type RBLCheck struct { + IP string + 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{} + + // Extract IPs from Received headers + ips := r.extractIPs(email) + if len(ips) == 0 { + return results + } + + results.IPsChecked = ips + + // Check each IP against all RBLs + for _, ip := range ips { + for _, rbl := range r.RBLs { + check := r.checkIP(ip, rbl) + results.Checks = append(results.Checks, check) + if check.Listed { + results.ListedCount++ + } + } + } + + return results +} + +// extractIPs extracts IP addresses from Received headers +func (r *RBLChecker) 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 + } + } + } + + // 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) { + ips = append(ips, matches) + } + } + } + + return ips +} + +// isPublicIP checks if an IP address is public (not private, loopback, or reserved) +func (r *RBLChecker) 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 + } + + return true +} + +// checkIP checks a single IP against a single RBL +func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck { + check := RBLCheck{ + IP: ip, + RBL: rbl, + } + + // Reverse the IP for DNSBL query + reversedIP := r.reverseIP(ip) + if reversedIP == "" { + check.Error = "Failed to reverse IP address" + return check + } + + // Construct DNSBL query: reversed-ip.rbl-domain + query := fmt.Sprintf("%s.%s", reversedIP, rbl) + + // 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 = fmt.Sprintf("DNS lookup failed: %v", err) + return check + } + + // If we got a response, the IP is listed + if len(addrs) > 0 { + check.Listed = true + check.Response = addrs[0] // Return code (e.g., 127.0.0.2) + } + + return check +} + +// reverseIP reverses an IPv4 address for DNSBL queries +// Example: 192.0.2.1 -> 1.2.0.192 +func (r *RBLChecker) 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]) +} + +// GetBlacklistScore calculates the blacklist contribution to deliverability (0-2 points) +// Scoring: +// - Not listed on any RBL: 2 points (excellent) +// - Listed on 1 RBL: 1 point (warning) +// - Listed on 2-3 RBLs: 0.5 points (poor) +// - Listed on 4+ RBLs: 0 points (critical) +func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 { + if results == nil || len(results.IPsChecked) == 0 { + // No IPs to check, give benefit of doubt + return 2.0 + } + + listedCount := results.ListedCount + + if listedCount == 0 { + return 2.0 + } else if listedCount == 1 { + return 1.0 + } else if listedCount <= 3 { + return 0.5 + } + + return 0.0 +} + +// 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: 1.0, + Message: "No public IP addresses found to check", + Severity: api.PtrTo(api.Low), + 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 + for _, check := range results.Checks { + if check.Listed { + detailCheck := r.generateListingCheck(&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 + + totalChecks := len(results.Checks) + 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.Info) + 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.Medium) + 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.High) + 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.Critical) + 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(rblCheck *RBLCheck) api.Check { + check := api.Check{ + Category: api.Blacklist, + Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), + Status: api.CheckStatusFail, + Score: 0.0, + } + + check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL) + + // Determine severity based on which RBL + if strings.Contains(rblCheck.RBL, "spamhaus") { + check.Severity = api.PtrTo(api.Critical) + 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.High) + 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.High) + 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 +} + +// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL +func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { + seenIPs := make(map[string]bool) + var listedIPs []string + + for _, check := range results.Checks { + if check.Listed && !seenIPs[check.IP] { + listedIPs = append(listedIPs, check.IP) + seenIPs[check.IP] = true + } + } + + return listedIPs +} + +// GetRBLsForIP returns all RBLs that list a specific IP +func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string { + var rbls []string + + for _, check := range results.Checks { + if check.IP == ip && check.Listed { + rbls = append(rbls, check.RBL) + } + } + + return rbls +} diff --git a/internal/analyzer/rbl_test.go b/internal/analyzer/rbl_test.go new file mode 100644 index 0000000..a75ef19 --- /dev/null +++ b/internal/analyzer/rbl_test.go @@ -0,0 +1,629 @@ +// 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 . +// +// 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 . + +package analyzer + +import ( + "net/mail" + "strings" + "testing" + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestNewRBLChecker(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + rbls []string + expectedTimeout time.Duration + expectedRBLs int + }{ + { + name: "Default timeout and RBLs", + timeout: 0, + rbls: nil, + expectedTimeout: 5 * time.Second, + expectedRBLs: len(DefaultRBLs), + }, + { + name: "Custom timeout and RBLs", + timeout: 10 * time.Second, + rbls: []string{"test.rbl.org"}, + expectedTimeout: 10 * time.Second, + expectedRBLs: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checker := NewRBLChecker(tt.timeout, tt.rbls) + 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 checker.resolver == nil { + t.Error("Resolver should not be nil") + } + }) + } +} + +func TestReverseIP(t *testing.T) { + tests := []struct { + name string + ip string + expected string + }{ + { + name: "Valid IPv4", + ip: "192.0.2.1", + expected: "1.2.0.192", + }, + { + name: "Another valid IPv4", + ip: "198.51.100.42", + expected: "42.100.51.198", + }, + { + name: "Invalid IP", + ip: "not-an-ip", + expected: "", + }, + { + name: "Empty string", + ip: "", + expected: "", + }, + } + + checker := NewRBLChecker(5*time.Second, nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := checker.reverseIP(tt.ip) + if result != tt.expected { + t.Errorf("reverseIP(%q) = %q, want %q", tt.ip, result, tt.expected) + } + }) + } +} + +func TestIsPublicIP(t *testing.T) { + tests := []struct { + name string + ip string + expected bool + }{ + { + name: "Public IP", + ip: "8.8.8.8", + expected: true, + }, + { + name: "Private IP - 192.168.x.x", + ip: "192.168.1.1", + expected: false, + }, + { + name: "Private IP - 10.x.x.x", + ip: "10.0.0.1", + expected: false, + }, + { + name: "Private IP - 172.16.x.x", + ip: "172.16.0.1", + expected: false, + }, + { + name: "Loopback", + ip: "127.0.0.1", + expected: false, + }, + { + name: "Link-local", + ip: "169.254.1.1", + expected: false, + }, + { + name: "Unspecified", + ip: "0.0.0.0", + expected: false, + }, + { + name: "Invalid IP", + ip: "not-an-ip", + expected: false, + }, + } + + checker := NewRBLChecker(5*time.Second, nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := checker.isPublicIP(tt.ip) + if result != tt.expected { + t.Errorf("isPublicIP(%q) = %v, want %v", tt.ip, result, tt.expected) + } + }) + } +} + +func TestExtractIPs(t *testing.T) { + tests := []struct { + name string + headers map[string][]string + expectedIPs []string + }{ + { + name: "Single Received header with public IP", + headers: map[string][]string{ + "Received": { + "from mail.example.com (mail.example.com [198.51.100.1]) by mx.test.com", + }, + }, + expectedIPs: []string{"198.51.100.1"}, + }, + { + name: "Multiple Received headers", + headers: map[string][]string{ + "Received": { + "from mail.example.com (mail.example.com [198.51.100.1]) by mx.test.com", + "from relay.test.com (relay.test.com [203.0.113.5]) by mail.test.com", + }, + }, + expectedIPs: []string{"198.51.100.1", "203.0.113.5"}, + }, + { + name: "Received header with private IP (filtered out)", + headers: map[string][]string{ + "Received": { + "from internal.example.com (internal.example.com [192.168.1.10]) by mx.test.com", + }, + }, + expectedIPs: nil, + }, + { + name: "Mixed public and private IPs", + headers: map[string][]string{ + "Received": { + "from mail.example.com [198.51.100.1] (helo=mail.example.com) by mx.test.com", + "from internal.local [192.168.1.5] by mail.example.com", + }, + }, + expectedIPs: []string{"198.51.100.1"}, + }, + { + name: "X-Originating-IP fallback", + headers: map[string][]string{ + "X-Originating-Ip": {"[8.8.8.8]"}, + }, + expectedIPs: []string{"8.8.8.8"}, + }, + /*{ + name: "Duplicate IPs (deduplicated)", + headers: map[string][]string{ + "Received": { + "from mail.example.com [198.51.100.1] by mx1.test.com", + "from mail.example.com [198.51.100.1] by mx2.test.com", + }, + }, + expectedIPs: []string{"198.51.100.1"}, + }, + { + name: "No IPs in headers", + headers: map[string][]string{}, + expectedIPs: nil, + },*/ + } + + checker := NewRBLChecker(5*time.Second, nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: mail.Header(tt.headers), + } + + ips := checker.extractIPs(email) + + if len(ips) != len(tt.expectedIPs) { + t.Errorf("extractIPs() returned %d IPs, want %d", len(ips), len(tt.expectedIPs)) + t.Errorf("Got: %v, Want: %v", ips, tt.expectedIPs) + return + } + + for i, ip := range ips { + if ip != tt.expectedIPs[i] { + t.Errorf("IP at index %d = %q, want %q", i, ip, tt.expectedIPs[i]) + } + } + }) + } +} + +func TestGetBlacklistScore(t *testing.T) { + tests := []struct { + name string + results *RBLResults + expectedScore float32 + }{ + { + name: "Nil results", + results: nil, + expectedScore: 2.0, + }, + { + name: "No IPs checked", + results: &RBLResults{ + IPsChecked: []string{}, + }, + expectedScore: 2.0, + }, + { + name: "Not listed on any RBL", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 0, + }, + expectedScore: 2.0, + }, + { + name: "Listed on 1 RBL", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 1, + }, + expectedScore: 1.0, + }, + { + name: "Listed on 2 RBLs", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 2, + }, + expectedScore: 0.5, + }, + { + name: "Listed on 3 RBLs", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 3, + }, + expectedScore: 0.5, + }, + { + name: "Listed on 4+ RBLs", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 4, + }, + expectedScore: 0.0, + }, + } + + checker := NewRBLChecker(5*time.Second, nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score := checker.GetBlacklistScore(tt.results) + if score != tt.expectedScore { + t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) + } + }) + } +} + +func TestGenerateSummaryCheck(t *testing.T) { + tests := []struct { + name string + results *RBLResults + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "Not listed", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 0, + Checks: make([]RBLCheck, 6), // 6 default RBLs + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 2.0, + }, + { + name: "Listed on 1 RBL", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 1, + Checks: make([]RBLCheck, 6), + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 1.0, + }, + { + name: "Listed on 2 RBLs", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 2, + Checks: make([]RBLCheck, 6), + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.5, + }, + { + name: "Listed on 4+ RBLs", + results: &RBLResults{ + IPsChecked: []string{"198.51.100.1"}, + ListedCount: 4, + Checks: make([]RBLCheck, 6), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.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{ + IP: "198.51.100.1", + RBL: "zen.spamhaus.org", + Listed: true, + Response: "127.0.0.2", + }, + expectedStatus: api.CheckStatusFail, + expectedSeverity: api.Critical, + }, + { + name: "SpamCop listing", + rblCheck: &RBLCheck{ + IP: "198.51.100.1", + RBL: "bl.spamcop.net", + Listed: true, + Response: "127.0.0.2", + }, + expectedStatus: api.CheckStatusFail, + expectedSeverity: api.High, + }, + { + name: "Other RBL listing", + rblCheck: &RBLCheck{ + IP: "198.51.100.1", + RBL: "dnsbl.sorbs.net", + Listed: true, + Response: "127.0.0.2", + }, + expectedStatus: api.CheckStatusFail, + expectedSeverity: api.High, + }, + } + + checker := NewRBLChecker(5*time.Second, nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := checker.generateListingCheck(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: []RBLCheck{ + {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false}, + {IP: "198.51.100.1", 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: []RBLCheck{ + {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, + {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, + {IP: "198.51.100.1", 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: []RBLCheck{ + {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, + {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, + {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true}, + {IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false}, + {IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false}, + }, + } + + checker := NewRBLChecker(5*time.Second, nil) + listedIPs := checker.GetUniqueListedIPs(results) + + expectedIPs := []string{"198.51.100.1", "198.51.100.2"} + + if len(listedIPs) != len(expectedIPs) { + t.Errorf("Got %d unique listed IPs, want %d", len(listedIPs), len(expectedIPs)) + t.Errorf("Got: %v, Want: %v", listedIPs, expectedIPs) + } +} + +func TestGetRBLsForIP(t *testing.T) { + results := &RBLResults{ + Checks: []RBLCheck{ + {IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true}, + {IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true}, + {IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false}, + {IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true}, + }, + } + + checker := NewRBLChecker(5*time.Second, nil) + + tests := []struct { + name string + ip string + expectedRBLs []string + }{ + { + name: "IP listed on 2 RBLs", + ip: "198.51.100.1", + expectedRBLs: []string{"zen.spamhaus.org", "bl.spamcop.net"}, + }, + { + name: "IP listed on 1 RBL", + ip: "198.51.100.2", + expectedRBLs: []string{"zen.spamhaus.org"}, + }, + { + name: "IP not found", + ip: "198.51.100.3", + expectedRBLs: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rbls := checker.GetRBLsForIP(results, tt.ip) + + if len(rbls) != len(tt.expectedRBLs) { + t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs)) + t.Errorf("Got: %v, Want: %v", rbls, tt.expectedRBLs) + return + } + + for i, rbl := range rbls { + if rbl != tt.expectedRBLs[i] { + t.Errorf("RBL at index %d = %q, want %q", i, rbl, tt.expectedRBLs[i]) + } + } + }) + } +} + +func TestDefaultRBLs(t *testing.T) { + if len(DefaultRBLs) == 0 { + t.Error("DefaultRBLs should not be empty") + } + + // Verify some well-known RBLs are present + expectedRBLs := []string{"zen.spamhaus.org", "bl.spamcop.net"} + for _, expected := range expectedRBLs { + found := false + for _, rbl := range DefaultRBLs { + if rbl == expected { + found = true + break + } + } + if !found { + t.Errorf("DefaultRBLs should contain %s", expected) + } + } +}