From a146940a65be5766f85f7c3c93168f4c1502ce96 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 23 Feb 2026 04:25:43 +0700 Subject: [PATCH 01/52] Improve FCrDNS UI: hide non-matching IPs when match exists Closes: https://github.com/happyDomain/happydeliver/issues/4 --- .../PtrForwardRecordsDisplay.svelte | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/PtrForwardRecordsDisplay.svelte b/web/src/lib/components/PtrForwardRecordsDisplay.svelte index 77ce6c8..8ed723b 100644 --- a/web/src/lib/components/PtrForwardRecordsDisplay.svelte +++ b/web/src/lib/components/PtrForwardRecordsDisplay.svelte @@ -21,6 +21,11 @@ ); const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0); + + let showDifferent = $state(false); + const differentCount = $derived( + ptrForwardRecords ? ptrForwardRecords.filter((ip) => ip !== senderIp).length : 0, + ); {#if ptrRecords && ptrRecords.length > 0} @@ -63,15 +68,31 @@
Forward Resolution (A/AAAA): {#each ptrForwardRecords as ip} -
- {#if senderIp && ip === senderIp} - Match - {:else} - Different - {/if} - {ip} -
+ {#if ip === senderIp || !fcrDnsIsValid || showDifferent} +
+ {#if senderIp && ip === senderIp} + Match + {:else} + Different + {/if} + {ip} +
+ {/if} {/each} + {#if fcrDnsIsValid && differentCount > 0} +
+ +
+ {/if}
{#if fcrDnsIsValid}
From b619ebf8c3696c08278e4406ef81882d42159de6 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 11:38:09 +0700 Subject: [PATCH 02/52] Display permerror (SPF test) as error: text-danger --- web/src/lib/components/AuthenticationCard.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 097dff1..93531e7 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -19,6 +19,7 @@ case "domain_pass": case "orgdomain_pass": return "text-success"; + case "permerror": case "error": case "fail": case "missing": @@ -51,6 +52,7 @@ case "neutral": case "invalid": case "null": + case "permerror": case "error": case "null_smtp": case "null_header": From 7b9c45fb68189e6d1c1986f4c31d084be8dde293 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 11:42:28 +0700 Subject: [PATCH 03/52] summary: color SPF error in red --- web/src/lib/components/SummaryCard.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte index dd0637a..fe8af8e 100644 --- a/web/src/lib/components/SummaryCard.svelte +++ b/web/src/lib/components/SummaryCard.svelte @@ -113,7 +113,7 @@ } else if (spfResult === "temperror" || spfResult === "permerror") { segments.push({ text: "encountered an error", - highlight: { color: "warning", bold: true }, + highlight: { color: "danger", bold: true }, link: "#authentication-spf", }); segments.push({ text: ", check your SPF record configuration" }); From 9679b381c7a225af2bf8153defcda2ddcf44be71 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 12:04:43 +0700 Subject: [PATCH 04/52] fix: mark Message-ID as invalid when multiple headers are present --- pkg/analyzer/headers.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index b7ff3bb..2a1bae4 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -320,6 +320,10 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp valid = false headerIssues = append(headerIssues, "Invalid Message-ID format (should be )") } + if len(email.Header["Message-Id"]) > 1 { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"]))) + } case "Date": // Validate date format if _, err := h.parseEmailDate(value); err != nil { From 4245f93ce4fddffbd151ec1b871ca61a920ecfb1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 12:14:45 +0700 Subject: [PATCH 05/52] Add MIME-Version recommended header check Validate MIME-Version header value equals "1.0" and subtract 5 points from the score if the header is present but invalid. Absence is not penalized. --- pkg/analyzer/headers.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 2a1bae4..37718bb 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -109,6 +109,13 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int maxGrade -= 1 } + // Check MIME-Version header (-5 points if present but not "1.0") + if check, exists := headers["mime-version"]; exists && check.Present { + if check.Valid != nil && !*check.Valid { + score -= 5 + } + } + // Check Message-ID format (10 points) if check, exists := headers["message-id"]; exists && check.Present { // If Valid is set and true, award points @@ -266,6 +273,10 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults headers[strings.ToLower(headerName)] = *check } + // Check MIME-Version header (recommended but absence is not penalized) + mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended") + headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck + // Check optional headers optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"} for _, headerName := range optionalHeaders { @@ -330,6 +341,11 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp valid = false headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err)) } + case "MIME-Version": + if value != "1.0" { + valid = false + headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value)) + } case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path": // Parse address header using net/mail and get normalized address if normalizedAddr, err := h.validateAddressHeader(value); err != nil { From f9c5c815d1b95a4e1853a1888fd9714e1b7f0edd Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 12:22:41 +0700 Subject: [PATCH 06/52] spamassassin: disable Validity network rules scoring --- docker/spamassassin/local.cf | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docker/spamassassin/local.cf b/docker/spamassassin/local.cf index c248ef6..ce9a31c 100644 --- a/docker/spamassassin/local.cf +++ b/docker/spamassassin/local.cf @@ -48,3 +48,14 @@ rbl_timeout 5 # Don't use user-specific rules user_scores_dsn_timeout 3 user_scores_sql_override 0 + +# Disable Validity network rules +dns_query_restriction deny sa-trusted.bondedsender.org +dns_query_restriction deny sa-accredit.habeas.com +dns_query_restriction deny bl.score.senderscore.com +score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0 +score RCVD_IN_VALIDITY_RPBL_BLOCKED 0 +score RCVD_IN_VALIDITY_SAFE_BLOCKED 0 +score RCVD_IN_VALIDITY_CERTIFIED 0 +score RCVD_IN_VALIDITY_RPBL 0 +score RCVD_IN_VALIDITY_SAFE 0 \ No newline at end of file From 3cc39c9c544a98fc00c677110d76b5c33ee3b0fb Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 14:23:47 +0700 Subject: [PATCH 07/52] rbl: add more RBL providers Add 8 new RBL providers (SpamRats, PSBL, DroneBL, Mailspike, RBL-DNS and NSZones). --- pkg/analyzer/rbl.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 5fcb939..923f939 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -48,6 +48,14 @@ var DefaultRBLs = []string{ "b.barracudacentral.org", // Barracuda "cbl.abuseat.org", // CBL (Composite Blocking List) "dnsbl-1.uceprotect.net", // UCEPROTECT Level 1 + "spam.spamrats.com", // SpamRats SPAM + "dyna.spamrats.com", // SpamRats dynamic IPs + "psbl.surriel.com", // PSBL + "dnsbl.dronebl.org", // DroneBL + "bl.mailspike.net", // Mailspike BL + "z.mailspike.net", // Mailspike Z + "bl.rbl-dns.com", // RBL-DNS + "bl.nszones.com", // NSZones } // NewRBLChecker creates a new RBL checker with configurable timeout and RBL list From 28424729a5909ba7489d2a4e0a0dd66b6e6407a6 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 14:24:33 +0700 Subject: [PATCH 08/52] rbl: support informational-only RBL entries Add DefaultInformationalRBLs (UCEPROTECT L2/L3) and track listings separately via RelevantListedCount so these broader lists are displayed but excluded from the deliverability score calculation. --- pkg/analyzer/rbl.go | 53 ++++++++++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 923f939..ff0e813 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -34,10 +34,11 @@ import ( // RBLChecker checks IP addresses against DNS-based blacklists type RBLChecker struct { - Timeout time.Duration - RBLs []string - CheckAllIPs bool // Check all IPs found in headers, not just the first one - resolver *net.Resolver + Timeout time.Duration + RBLs []string + CheckAllIPs bool // Check all IPs found in headers, not just the first one + resolver *net.Resolver + informationalSet map[string]bool } // DefaultRBLs is a list of commonly used RBL providers @@ -48,6 +49,8 @@ var DefaultRBLs = []string{ "b.barracudacentral.org", // Barracuda "cbl.abuseat.org", // CBL (Composite Blocking List) "dnsbl-1.uceprotect.net", // UCEPROTECT Level 1 + "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational) + "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational) "spam.spamrats.com", // SpamRats SPAM "dyna.spamrats.com", // SpamRats dynamic IPs "psbl.surriel.com", // PSBL @@ -58,6 +61,13 @@ var DefaultRBLs = []string{ "bl.nszones.com", // NSZones } +// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score. +// These are typically broader lists where being listed is less definitive. +var DefaultInformationalRBLs = []string{ + "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives + "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring +} + // NewRBLChecker creates a new RBL checker with configurable timeout and RBL list func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLChecker { if timeout == 0 { @@ -66,21 +76,25 @@ func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLC if len(rbls) == 0 { rbls = DefaultRBLs } + informationalSet := make(map[string]bool, len(DefaultInformationalRBLs)) + for _, rbl := range DefaultInformationalRBLs { + informationalSet[rbl] = true + } return &RBLChecker{ - Timeout: timeout, - RBLs: rbls, - CheckAllIPs: checkAllIPs, - resolver: &net.Resolver{ - PreferGo: true, - }, + Timeout: timeout, + RBLs: rbls, + CheckAllIPs: checkAllIPs, + 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 + 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 } // CheckEmail checks all IPs found in the email headers against RBLs @@ -104,6 +118,9 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { results.Checks[ip] = append(results.Checks[ip], check) if check.Listed { results.ListedCount++ + if !r.informationalSet[rbl] { + results.RelevantListedCount++ + } } } @@ -276,14 +293,20 @@ func (r *RBLChecker) reverseIP(ipStr string) string { return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) } -// CalculateRBLScore calculates the blacklist contribution to deliverability +// CalculateRBLScore calculates the blacklist contribution to deliverability. +// Informational RBLs are not counted in the score. func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) { if results == nil || len(results.IPsChecked) == 0 { // No IPs to check, give benefit of doubt return 100, "" } - percentage := 100 - results.ListedCount*100/len(r.RBLs) + scoringRBLCount := len(r.RBLs) - len(r.informationalSet) + if scoringRBLCount <= 0 { + return 100, "A+" + } + + percentage := 100 - results.RelevantListedCount*100/scoringRBLCount return percentage, ScoreToGrade(percentage) } From 55e9bcd3d043988cb7edb061f9661e40e4f00f9c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 16:18:10 +0700 Subject: [PATCH 09/52] refactor: handle DNS whitelists Introduce a single DNSListChecker struct with flags to avoid code duplication with already existing RBL checker. --- api/openapi.yaml | 13 ++ internal/config/config.go | 2 + pkg/analyzer/analyzer.go | 5 +- pkg/analyzer/rbl.go | 165 ++++++++++---------- pkg/analyzer/rbl_test.go | 26 +-- pkg/analyzer/report.go | 16 +- pkg/analyzer/report_test.go | 10 +- web/src/lib/components/WhitelistCard.svelte | 62 ++++++++ web/src/lib/components/index.ts | 1 + web/src/routes/test/[test]/+page.svelte | 52 ++++-- 10 files changed, 235 insertions(+), 117 deletions(-) create mode 100644 web/src/lib/components/WhitelistCard.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index 5c628fd..f724ae6 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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: diff --git a/internal/config/config.go b/internal/config/config.go index 4a335c9..468a2aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 }, } diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index e7ae561..83eafe6 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -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 } diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index ff0e813..44c6e99 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -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 } diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index a1de270..1dd1262 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -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") @@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) { func TestGetBlacklistScore(t *testing.T) { tests := []struct { name string - results *RBLResults + results *DNSListResults expectedScore int }{ { @@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "No IPs checked", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{}, }, expectedScore: 100, }, { name: "Not listed on any RBL", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, @@ -290,7 +290,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 1 RBL", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, }, @@ -298,7 +298,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 2 RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, }, @@ -306,7 +306,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 3 RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 3, }, @@ -314,7 +314,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 4+ RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 4, }, @@ -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) } @@ -335,7 +335,7 @@ func TestGetBlacklistScore(t *testing.T) { } func TestGetUniqueListedIPs(t *testing.T) { - results := &RBLResults{ + results := &DNSListResults{ Checks: map[string][]api.BlacklistCheck{ "198.51.100.1": { {Rbl: "zen.spamhaus.org", Listed: true}, @@ -363,7 +363,7 @@ func TestGetUniqueListedIPs(t *testing.T) { } func TestGetRBLsForIP(t *testing.T) { - results := &RBLResults{ + results := &DNSListResults{ Checks: map[string][]api.BlacklistCheck{ "198.51.100.1": { {Rbl: "zen.spamhaus.org", Listed: true}, @@ -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)) diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index dc420fb..bd12960 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -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) diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index 5a325b1..82e923e 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -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 diff --git a/web/src/lib/components/WhitelistCard.svelte b/web/src/lib/components/WhitelistCard.svelte new file mode 100644 index 0000000..ee0b0e2 --- /dev/null +++ b/web/src/lib/components/WhitelistCard.svelte @@ -0,0 +1,62 @@ + + +
+
+

+ + + Whitelist Checks + + Informational +

+
+
+

+ DNS whitelists identify trusted senders. Being listed here is a positive signal, but has + no impact on the overall score. +

+ +
+ {#each Object.entries(whitelists) as [ip, checks]} +
+
+ + {ip} +
+ + + {#each checks as check} + + + + + {/each} + +
+ + {check.error + ? "Error" + : check.listed + ? "Listed" + : "Not listed"} + + {check.rbl}
+
+ {/each} +
+
+
diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index d577399..8ed409c 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -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"; diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index c5add96..0c8ea9d 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -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; + let testId = $derived(page.params.test); let test = $state(null); let report = $state(null); @@ -321,17 +324,46 @@ {/if} - {#if report.blacklists && Object.keys(report.blacklists).length > 0} -
-
- + {#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)} + + {/snippet} + + + {#snippet whitelistChecks(whitelists: BlacklistRecords)} + + {/snippet} + + + {#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1} +
+
+ {@render blacklistChecks(report.blacklists, report)} +
+
+ {@render whitelistChecks(report.whitelists)}
+ {:else} + {#if report.blacklists && Object.keys(report.blacklists).length > 0} +
+
+ {@render blacklistChecks(report.blacklists, report)} +
+
+ {/if} + + {#if report.whitelists && Object.keys(report.whitelists).length > 0} +
+
+ {@render whitelistChecks(report.whitelists)} +
+
+ {/if} {/if} From 2a2bfe46a858b7cc2030f97e166337867a86732b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 16:26:40 +0700 Subject: [PATCH 10/52] fix: various small fixes and improvements - Add 'skipped' to authentication result enum in OpenAPI spec - Fix optional chaining on bimiResult.details check - Add rbls field to AppConfig interface - Restrict theme storage to valid 'light'/'dark' values only - Fix null coalescing for blacklist result data - Fix survey source to use domain instead of ip --- api/openapi.yaml | 2 +- web/src/lib/components/SummaryCard.svelte | 2 +- web/src/lib/stores/config.ts | 1 + web/src/lib/stores/theme.ts | 2 +- web/src/routes/blacklist/[ip]/+page.svelte | 2 +- web/src/routes/domain/[domain]/+page.svelte | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index f724ae6..c0c3c70 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -789,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: diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte index fe8af8e..5d93513 100644 --- a/web/src/lib/components/SummaryCard.svelte +++ b/web/src/lib/components/SummaryCard.svelte @@ -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 " }); diff --git a/web/src/lib/stores/config.ts b/web/src/lib/stores/config.ts index 87662ba..c393dd2 100644 --- a/web/src/lib/stores/config.ts +++ b/web/src/lib/stores/config.ts @@ -25,6 +25,7 @@ interface AppConfig { report_retention?: number; survey_url?: string; custom_logo_url?: string; + rbls?: string[]; } const defaultConfig: AppConfig = { diff --git a/web/src/lib/stores/theme.ts b/web/src/lib/stores/theme.ts index 362202b..ea24293 100644 --- a/web/src/lib/stores/theme.ts +++ b/web/src/lib/stores/theme.ts @@ -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"; }; diff --git a/web/src/routes/blacklist/[ip]/+page.svelte b/web/src/routes/blacklist/[ip]/+page.svelte index 180bfde..0cddb22 100644 --- a/web/src/routes/blacklist/[ip]/+page.svelte +++ b/web/src/routes/blacklist/[ip]/+page.svelte @@ -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"; } diff --git a/web/src/routes/domain/[domain]/+page.svelte b/web/src/routes/domain/[domain]/+page.svelte index e191192..d866e21 100644 --- a/web/src/routes/domain/[domain]/+page.svelte +++ b/web/src/routes/domain/[domain]/+page.svelte @@ -130,7 +130,7 @@
From da93d6d706dedc3de744081172de9c0803276521 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 9 Mar 2026 12:47:24 +0700 Subject: [PATCH 11/52] Add rspamd tests --- pkg/analyzer/rspamd_test.go | 394 ++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 pkg/analyzer/rspamd_test.go diff --git a/pkg/analyzer/rspamd_test.go b/pkg/analyzer/rspamd_test.go new file mode 100644 index 0000000..180bafd --- /dev/null +++ b/pkg/analyzer/rspamd_test.go @@ -0,0 +1,394 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 ( + "bytes" + "net/mail" + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestAnalyzeRspamdNoHeaders(t *testing.T) { + analyzer := NewRspamdAnalyzer() + email := &EmailMessage{Header: make(mail.Header)} + + result := analyzer.AnalyzeRspamd(email) + + if result != nil { + t.Errorf("Expected nil for email without rspamd headers, got %+v", result) + } +} + +func TestParseSpamdResult(t *testing.T) { + tests := []struct { + name string + header string + expectedScore float32 + expectedThreshold float32 + expectedIsSpam bool + expectedSymbols map[string]float32 + expectedSymParams map[string]string + }{ + { + name: "Clean email negative score", + header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]", + expectedScore: -3.91, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "DATE_IN_PAST": 0.10, + "ALL_TRUSTED": -1.00, + }, + expectedSymParams: map[string]string{ + "ALL_TRUSTED": "trusted", + }, + }, + { + name: "Spam email True flag", + header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)", + expectedScore: 16.50, + expectedThreshold: 15.00, + expectedIsSpam: true, + expectedSymbols: map[string]float32{ + "BAYES_99": 5.00, + "SPOOFED_SENDER": 3.50, + }, + expectedSymParams: map[string]string{ + "BAYES_99": "1.00", + }, + }, + { + name: "Zero threshold uses default", + header: "default: False [1.00 / 0.00]", + expectedScore: 1.00, + expectedThreshold: rspamdDefaultAddHeaderThreshold, + expectedIsSpam: false, + expectedSymbols: map[string]float32{}, + }, + { + name: "Symbol without params", + header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)", + expectedScore: 2.00, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "MISSING_DATE": 1.00, + }, + }, + { + name: "Case-insensitive true flag", + header: "default: true [8.00 / 6.00]", + expectedScore: 8.00, + expectedThreshold: 6.00, + expectedIsSpam: true, + expectedSymbols: map[string]float32{}, + }, + } + + analyzer := NewRspamdAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &api.RspamdResult{ + Symbols: make(map[string]api.RspamdSymbol), + } + analyzer.parseSpamdResult(tt.header, result) + + if result.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore) + } + if result.Threshold != tt.expectedThreshold { + t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold) + } + if result.IsSpam != tt.expectedIsSpam { + t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam) + } + for symName, expectedScore := range tt.expectedSymbols { + sym, ok := result.Symbols[symName] + if !ok { + t.Errorf("Symbol %s not found", symName) + continue + } + if sym.Score != expectedScore { + t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore) + } + } + for symName, expectedParam := range tt.expectedSymParams { + sym, ok := result.Symbols[symName] + if !ok { + t.Errorf("Symbol %s not found for params check", symName) + continue + } + if sym.Params == nil { + t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam) + } else if *sym.Params != expectedParam { + t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam) + } + } + }) + } +} + +func TestAnalyzeRspamd(t *testing.T) { + tests := []struct { + name string + headers map[string]string + expectedScore float32 + expectedThreshold float32 + expectedIsSpam bool + expectedServer *string + expectedSymCount int + }{ + { + name: "Full headers clean email", + headers: map[string]string{ + "X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]", + "X-Rspamd-Score": "-3.91", + "X-Rspamd-Server": "mail.example.com", + }, + expectedScore: -3.91, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedServer: func() *string { s := "mail.example.com"; return &s }(), + expectedSymCount: 1, + }, + { + name: "X-Rspamd-Score overrides spamd result score", + headers: map[string]string{ + "X-Spamd-Result": "default: False [2.00 / 15.00]", + "X-Rspamd-Score": "3.50", + }, + expectedScore: 3.50, + expectedThreshold: 15.00, + expectedIsSpam: false, + }, + { + name: "Spam email above threshold", + headers: map[string]string{ + "X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)", + "X-Rspamd-Score": "16.00", + }, + expectedScore: 16.00, + expectedThreshold: 15.00, + expectedIsSpam: true, + expectedSymCount: 1, + }, + { + name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold", + headers: map[string]string{ + "X-Rspamd-Score": "2.00", + }, + expectedScore: 2.00, + expectedIsSpam: false, + }, + { + name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold", + headers: map[string]string{ + "X-Rspamd-Score": "7.00", + }, + expectedScore: 7.00, + expectedIsSpam: true, + }, + { + name: "Server header is trimmed", + headers: map[string]string{ + "X-Rspamd-Score": "1.00", + "X-Rspamd-Server": " rspamd-01 ", + }, + expectedScore: 1.00, + expectedServer: func() *string { s := "rspamd-01"; return &s }(), + }, + } + + analyzer := NewRspamdAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{Header: make(mail.Header)} + for k, v := range tt.headers { + email.Header[k] = []string{v} + } + + result := analyzer.AnalyzeRspamd(email) + + if result == nil { + t.Fatal("Expected non-nil result") + } + if result.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore) + } + if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold { + t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold) + } + if result.IsSpam != tt.expectedIsSpam { + t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam) + } + if tt.expectedServer != nil { + if result.Server == nil { + t.Errorf("Server = nil, want %q", *tt.expectedServer) + } else if *result.Server != *tt.expectedServer { + t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer) + } + } + if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount { + t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount) + } + }) + } +} + +func TestCalculateRspamdScore(t *testing.T) { + tests := []struct { + name string + result *api.RspamdResult + expectedScore int + expectedGrade string + }{ + { + name: "Nil result (rspamd not installed)", + result: nil, + expectedScore: 100, + expectedGrade: "", + }, + { + name: "Score well below threshold", + result: &api.RspamdResult{ + Score: -3.91, + Threshold: 15.00, + }, + expectedScore: 100, + expectedGrade: "A+", + }, + { + name: "Score at zero", + result: &api.RspamdResult{ + Score: 0, + Threshold: 15.00, + }, + // 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A" + expectedScore: 100, + expectedGrade: "A", + }, + { + name: "Score at threshold (half of 2*threshold)", + result: &api.RspamdResult{ + Score: 15.00, + Threshold: 15.00, + }, + // 100 - round(15*100/(2*15)) = 100 - 50 = 50 + expectedScore: 50, + }, + { + name: "Score above 2*threshold", + result: &api.RspamdResult{ + Score: 31.00, + Threshold: 15.00, + }, + expectedScore: 0, + expectedGrade: "F", + }, + { + name: "Score exactly at 2*threshold", + result: &api.RspamdResult{ + Score: 30.00, + Threshold: 15.00, + }, + // 100 - round(30*100/30) = 100 - 100 = 0 + expectedScore: 0, + expectedGrade: "F", + }, + } + + analyzer := NewRspamdAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score, grade := analyzer.CalculateRspamdScore(tt.result) + + if score != tt.expectedScore { + t.Errorf("Score = %d, want %d", score, tt.expectedScore) + } + if tt.expectedGrade != "" && grade != tt.expectedGrade { + t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade) + } + }) + } +} + +const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00]; + BAYES_HAM(-3.00)[99%]; + RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from]; + R_DKIM_ALLOW(-0.20)[example.com:s=dkim]; + FROM_HAS_DN(0.00)[]; + MIME_GOOD(-0.10)[text/plain]; +X-Rspamd-Score: -3.91 +X-Rspamd-Server: rspamd-01.example.com +Date: Mon, 09 Mar 2026 10:00:00 +0000 +From: sender@example.com +To: test@happydomain.org +Subject: Test email +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +Hello world` + +func TestAnalyzeRspamdRealEmail(t *testing.T) { + email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + analyzer := NewRspamdAnalyzer() + result := analyzer.AnalyzeRspamd(email) + + if result == nil { + t.Fatal("Expected non-nil result") + } + if result.IsSpam { + t.Error("Expected IsSpam=false") + } + if result.Score != -3.91 { + t.Errorf("Score = %v, want -3.91", result.Score) + } + if result.Threshold != 15.00 { + t.Errorf("Threshold = %v, want 15.00", result.Threshold) + } + if result.Server == nil || *result.Server != "rspamd-01.example.com" { + t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server) + } + + expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"} + for _, sym := range expectedSymbols { + if _, ok := result.Symbols[sym]; !ok { + t.Errorf("Symbol %s not found", sym) + } + } + + score, _ := analyzer.CalculateRspamdScore(result) + if score != 100 { + t.Errorf("CalculateRspamdScore = %d, want 100", score) + } +} + From bb47bb7c29eb9e59780d631772081daa42d111b7 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 9 Mar 2026 12:52:12 +0700 Subject: [PATCH 12/52] fix: handle nested brackets in rspamd symbol params --- pkg/analyzer/rspamd.go | 5 +++-- pkg/analyzer/rspamd_test.go | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go index d394c62..c2ea1cf 100644 --- a/pkg/analyzer/rspamd.go +++ b/pkg/analyzer/rspamd.go @@ -111,8 +111,9 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul } // Parse symbols: SYMBOL(score)[params] - // Each symbol entry is separated by ";" - symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[([^\]]*)\])?`) + // Each symbol entry is separated by ";", so within each part we use a + // greedy match to capture params that may contain nested brackets. + symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`) for _, part := range strings.Split(header, ";") { part = strings.TrimSpace(part) matches := symbolRe.FindStringSubmatch(part) diff --git a/pkg/analyzer/rspamd_test.go b/pkg/analyzer/rspamd_test.go index 180bafd..de98fe8 100644 --- a/pkg/analyzer/rspamd_test.go +++ b/pkg/analyzer/rspamd_test.go @@ -104,6 +104,26 @@ func TestParseSpamdResult(t *testing.T) { expectedIsSpam: true, expectedSymbols: map[string]float32{}, }, + { + name: "Zero threshold with symbols containing nested brackets in params", + header: "default: False [0.90 / 0.00];\n" + + "\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" + + "\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" + + "\tMIME_TRACE(0.00)[0:+,1:+,2:~]", + expectedScore: 0.90, + expectedThreshold: rspamdDefaultAddHeaderThreshold, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "ARC_REJECT": 1.00, + "MIME_GOOD": -0.10, + "MIME_TRACE": 0.00, + }, + expectedSymParams: map[string]string{ + "ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}", + "MIME_GOOD": "multipart/alternative,text/plain", + "MIME_TRACE": "0:+,1:+,2:~", + }, + }, } analyzer := NewRspamdAnalyzer() From d9b9ea87c6dfab9f42a225ecd94c0bde8a19e7a6 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 9 Mar 2026 13:09:07 +0700 Subject: [PATCH 13/52] refactor: extract email path into standalone card component Move the received chain display out of BlacklistCard into EmailPathCard, giving it its own card styling and placing it as a dedicated section on the report page. --- web/src/lib/components/BlacklistCard.svelte | 10 ++-------- web/src/lib/components/EmailPathCard.svelte | 18 ++++++++++++++---- web/src/routes/test/[test]/+page.svelte | 11 ++++++++++- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte index 7f9b7f2..fec7b09 100644 --- a/web/src/lib/components/BlacklistCard.svelte +++ b/web/src/lib/components/BlacklistCard.svelte @@ -1,18 +1,16 @@
@@ -35,10 +33,6 @@
- {#if receivedChain} - - {/if} -
{#each Object.entries(blacklists) as [ip, checks]}
diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte index 8dc57b0..a4fda45 100644 --- a/web/src/lib/components/EmailPathCard.svelte +++ b/web/src/lib/components/EmailPathCard.svelte @@ -1,5 +1,6 @@ {#if receivedChain && receivedChain.length > 0} -
-
Email Path (Received Chain)
-
+
+
+

+ + Email Path +

+
+
{#each receivedChain as hop, i}
@@ -30,7 +40,7 @@ : "-"}
- {#if hop.with || hop.id} + {#if hop.with || hop.id || hop.from}

{#if hop.with} diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 0c8ea9d..10c4f22 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -9,6 +9,7 @@ BlacklistCard, ContentAnalysisCard, DnsRecordsCard, + EmailPathCard, ErrorDisplay, HeaderAnalysisCard, PendingState, @@ -294,6 +295,15 @@

+ + {#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0} +
+
+ +
+
+ {/if} + {#if report.dns_results}
@@ -329,7 +339,6 @@ {blacklists} blacklistGrade={report.summary?.blacklist_grade} blacklistScore={report.summary?.blacklist_score} - receivedChain={report.header_analysis?.received_chain} /> {/snippet} From 27650a3496ed6c6d5ab916d96d6495fc10be1877 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 9 Mar 2026 13:12:36 +0700 Subject: [PATCH 14/52] feat: add raw report display to rspamd card Add a collapsible Raw Report section to RspamdCard, storing the raw X-Spamd-Result header value and displaying it like SpamAssassin's report. --- api/openapi.yaml | 3 +++ pkg/analyzer/rspamd.go | 2 ++ web/src/lib/components/RspamdCard.svelte | 27 +++++++++++++++++++++--- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index c0c3c70..1a9cbbf 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -982,6 +982,9 @@ components: name: "BAYES_HAM" score: -1.9 params: "0.02" + report: + type: string + description: Full rspamd report (raw X-Spamd-Result header) RspamdSymbol: type: object diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go index c2ea1cf..f3f548b 100644 --- a/pkg/analyzer/rspamd.go +++ b/pkg/analyzer/rspamd.go @@ -58,6 +58,8 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult { // Parse X-Spamd-Result header (primary source for score, threshold, and symbols) // Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..." if spamdResult, ok := headers["X-Spamd-Result"]; ok { + report := strings.ReplaceAll(spamdResult, "; ", ";\n") + result.Report = &report a.parseSpamdResult(spamdResult, result) } diff --git a/web/src/lib/components/RspamdCard.svelte b/web/src/lib/components/RspamdCard.svelte index 2468f90..0db6378 100644 --- a/web/src/lib/components/RspamdCard.svelte +++ b/web/src/lib/components/RspamdCard.svelte @@ -17,8 +17,7 @@ const effectiveAction = $derived.by(() => { const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15; - if (rspamd.score >= rejectThreshold) - return { label: "Reject", cls: "bg-danger" }; + if (rspamd.score >= rejectThreshold) return { label: "Reject", cls: "bg-danger" }; if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD) return { label: "Add header", cls: "bg-warning text-dark" }; if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD) @@ -31,7 +30,7 @@

- + rspamd Analysis @@ -108,10 +107,32 @@

{/if} + + {#if rspamd.report} +
+ Raw Report +
{rspamd.report}
+
+ {/if}
diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index 8ed409c..a593801 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -23,5 +23,6 @@ export { default as RspamdCard } from "./RspamdCard.svelte"; export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte"; export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte"; export { default as SummaryCard } from "./SummaryCard.svelte"; +export { default as HistoryTable } from "./HistoryTable.svelte"; export { default as TinySurvey } from "./TinySurvey.svelte"; export { default as WhitelistCard } from "./WhitelistCard.svelte"; diff --git a/web/src/lib/stores/config.ts b/web/src/lib/stores/config.ts index c393dd2..962868c 100644 --- a/web/src/lib/stores/config.ts +++ b/web/src/lib/stores/config.ts @@ -26,6 +26,7 @@ interface AppConfig { survey_url?: string; custom_logo_url?: string; rbls?: string[]; + test_list_enabled?: boolean; } const defaultConfig: AppConfig = { diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 077f340..92bb4db 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -40,7 +40,17 @@ {/if} -
+ {#if $appConfig.test_list_enabled} + + {/if} +
Open-Source Email Deliverability Tester diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 7c23d10..b9259fe 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,12 +1,30 @@ + + + Test History - happyDeliver + + +
+
+
+
+

+ + Test History +

+ +
+ + {#if loading} +
+
+ Loading... +
+

Loading tests...

+
+ {:else if error} + + {:else if tests.length === 0} +
+ +

No tests yet

+

+ Send a test email to get your first deliverability + report. +

+ +
+ {:else} + + + + {#if totalPages > 1} + + {/if} + {/if} +
+
+
From 3eec5ce96655060eb94f28403daacb720e2c8115 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 9 Apr 2026 17:49:52 +0700 Subject: [PATCH 46/52] Remove unused xAlignedFrom prop from HeaderAnalysisCard --- web/src/lib/components/HeaderAnalysisCard.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index b26b492..73c39e8 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -11,7 +11,7 @@ headerScore?: number; } - let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props(); + let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
From 396c51974a9d81b6ea51ff9f24416fcb0c15c86c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 9 Apr 2026 18:36:18 +0700 Subject: [PATCH 47/52] Extract OpenAPI schemas to separate file and move models to internal/model package Split api/openapi.yaml schemas into api/schemas.yaml so structs can be generated independently from the API server code. Models now generate into internal/model/ via oapi-codegen, with the server referencing them through import-mapping. Moved PtrTo helper to internal/utils and removed storage.ReportSummary in favor of model.TestSummary. --- .gitignore | 4 +- api/config-models.yaml | 10 +- api/config-server.yaml | 3 + api/openapi.yaml | 1163 +--------------- api/schemas.yaml | 1173 +++++++++++++++++ generate.go | 2 +- internal/api/handlers.go | 167 +-- internal/storage/storage.go | 49 +- internal/{api/helpers.go => utils/ptr.go} | 8 +- pkg/analyzer/analyzer.go | 10 +- pkg/analyzer/authentication.go | 12 +- pkg/analyzer/authentication_arc.go | 25 +- pkg/analyzer/authentication_arc_test.go | 10 +- pkg/analyzer/authentication_bimi.go | 17 +- pkg/analyzer/authentication_bimi_test.go | 12 +- pkg/analyzer/authentication_dkim.go | 15 +- pkg/analyzer/authentication_dkim_test.go | 10 +- pkg/analyzer/authentication_dmarc.go | 17 +- pkg/analyzer/authentication_dmarc_test.go | 8 +- pkg/analyzer/authentication_iprev.go | 15 +- pkg/analyzer/authentication_iprev_test.go | 73 +- pkg/analyzer/authentication_spf.go | 25 +- pkg/analyzer/authentication_spf_test.go | 49 +- pkg/analyzer/authentication_test.go | 161 +-- pkg/analyzer/authentication_x_aligned_from.go | 17 +- .../authentication_x_aligned_from_test.go | 34 +- pkg/analyzer/authentication_x_google_dkim.go | 15 +- .../authentication_x_google_dkim_test.go | 12 +- pkg/analyzer/content.go | 97 +- pkg/analyzer/dns.go | 18 +- pkg/analyzer/dns_bimi.go | 19 +- pkg/analyzer/dns_dkim.go | 25 +- pkg/analyzer/dns_dmarc.go | 51 +- pkg/analyzer/dns_dmarc_test.go | 21 +- pkg/analyzer/dns_fcr.go | 4 +- pkg/analyzer/dns_mx.go | 19 +- pkg/analyzer/dns_spf.go | 45 +- pkg/analyzer/headers.go | 57 +- pkg/analyzer/headers_test.go | 24 +- pkg/analyzer/rbl.go | 23 +- pkg/analyzer/rbl_test.go | 6 +- pkg/analyzer/report.go | 40 +- pkg/analyzer/rspamd.go | 14 +- pkg/analyzer/rspamd_test.go | 18 +- pkg/analyzer/scoring.go | 8 +- pkg/analyzer/spamassassin.go | 25 +- pkg/analyzer/spamassassin_test.go | 33 +- 47 files changed, 1878 insertions(+), 1785 deletions(-) create mode 100644 api/schemas.yaml rename internal/{api/helpers.go => utils/ptr.go} (91%) diff --git a/.gitignore b/.gitignore index 7ece05e..e943630 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,5 @@ logs/ *.sqlite3 # OpenAPI generated files -internal/api/models.gen.go -internal/api/server.gen.go \ No newline at end of file +internal/api/server.gen.go +internal/model/types.gen.go diff --git a/api/config-models.yaml b/api/config-models.yaml index 9c3425c..aa2fb0e 100644 --- a/api/config-models.yaml +++ b/api/config-models.yaml @@ -1,5 +1,9 @@ -package: api +package: model generate: models: true - embedded-spec: false -output: internal/api/models.gen.go + embedded-spec: true +output: internal/model/types.gen.go +output-options: + skip-prune: true +import-mapping: + ./schemas.yaml: "-" diff --git a/api/config-server.yaml b/api/config-server.yaml index 20f8daf..347dbaf 100644 --- a/api/config-server.yaml +++ b/api/config-server.yaml @@ -1,5 +1,8 @@ package: api generate: gin-server: true + models: true embedded-spec: true output: internal/api/server.gen.go +import-mapping: + ./schemas.yaml: git.happydns.org/happyDeliver/internal/model diff --git a/api/openapi.yaml b/api/openapi.yaml index ee56cff..2dbf304 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -296,1165 +296,74 @@ paths: components: schemas: Test: - type: object - required: - - id - - email - - status - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Unique test identifier (base32-encoded with hyphens) - example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" - email: - type: string - format: email - description: Unique test email address - example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" - status: - type: string - enum: [pending, analyzed] - description: Current test status (pending = no report yet, analyzed = report available) - example: "analyzed" - + $ref: './schemas.yaml#/components/schemas/Test' TestResponse: - type: object - required: - - id - - email - - status - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Unique test identifier (base32-encoded with hyphens) - example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" - email: - type: string - format: email - example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" - status: - type: string - enum: [pending] - example: "pending" - message: - type: string - example: "Send your test email to the address above" - + $ref: './schemas.yaml#/components/schemas/TestResponse' Report: - type: object - required: - - id - - test_id - - score - - grade - - created_at - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Report identifier (base32-encoded with hyphens) - test_id: - type: string - pattern: '^[a-z0-9-]+$' - description: Associated test ID (base32-encoded with hyphens) - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall deliverability score as percentage (0-100) - example: 85 - 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" - summary: - $ref: '#/components/schemas/ScoreSummary' - authentication: - $ref: '#/components/schemas/AuthenticationResults' - spamassassin: - $ref: '#/components/schemas/SpamAssassinResult' - rspamd: - $ref: '#/components/schemas/RspamdResult' - dns_results: - $ref: '#/components/schemas/DNSResults' - blacklists: - type: object - additionalProperties: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: Map of IP addresses to their blacklist check results (array of checks per IP) - example: - "192.0.2.1": - - rbl: "zen.spamhaus.org" - 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: - $ref: '#/components/schemas/HeaderAnalysis' - raw_headers: - type: string - description: Raw email headers - created_at: - type: string - format: date-time - + $ref: './schemas.yaml#/components/schemas/Report' ScoreSummary: - type: object - required: - - dns_score - - dns_grade - - authentication_score - - authentication_grade - - spam_score - - spam_grade - - blacklist_score - - blacklist_grade - - header_score - - header_grade - - content_score - - content_grade - properties: - dns_score: - type: integer - minimum: 0 - maximum: 100 - description: DNS records score (in percentage) - example: 42 - dns_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" - authentication_score: - type: integer - minimum: 0 - maximum: 100 - description: SPF/DKIM/DMARC score (in percentage) - example: 28 - authentication_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" - spam_score: - type: integer - minimum: 0 - maximum: 100 - description: Spam filter score (SpamAssassin + rspamd combined, in percentage) - example: 15 - spam_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" - blacklist_score: - type: integer - minimum: 0 - maximum: 100 - description: Blacklist check score (in percentage) - example: 20 - blacklist_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" - header_score: - type: integer - minimum: 0 - maximum: 100 - description: Header quality score (in percentage) - example: 9 - header_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" - content_score: - type: integer - minimum: 0 - maximum: 100 - description: Content quality score (in percentage) - example: 18 - content_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" - + $ref: './schemas.yaml#/components/schemas/ScoreSummary' 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"] - + $ref: './schemas.yaml#/components/schemas/ContentAnalysis' ContentIssue: - type: object - required: - - type - - severity - - message - properties: - type: - type: string - enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link, dangerous_html] - description: Type of content issue - example: "missing_alt" - severity: - type: string - enum: [critical, high, medium, low, info] - description: Issue severity - 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: How to fix this issue - example: "Add descriptive alt text to all images for better accessibility and deliverability" - + $ref: './schemas.yaml#/components/schemas/ContentIssue' 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 - + $ref: './schemas.yaml#/components/schemas/LinkCheck' 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 - + $ref: './schemas.yaml#/components/schemas/ImageCheck' 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 - + $ref: './schemas.yaml#/components/schemas/HeaderAnalysis' 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, newsletter] - 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"] - + $ref: './schemas.yaml#/components/schemas/HeaderCheck' 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 - ip: - type: string - description: IP address of the sending server (IPv4 or IPv6) - example: "192.0.2.1" - reverse: - type: string - description: Reverse DNS (PTR record) for the IP address - example: "mail.example.com" - + $ref: './schemas.yaml#/components/schemas/ReceivedHop' DKIMDomainInfo: - type: object - required: - - domain - - org_domain - properties: - domain: - type: string - description: DKIM signature domain - example: "mail.example.com" - org_domain: - type: string - description: Organizational domain extracted from DKIM domain (using Public Suffix List) - example: "example.com" - + $ref: './schemas.yaml#/components/schemas/DKIMDomainInfo' DomainAlignment: - type: object - properties: - from_domain: - type: string - description: Domain from From header - example: "example.com" - from_org_domain: - type: string - description: Organizational domain extracted from From header (using Public Suffix List) - example: "example.com" - return_path_domain: - type: string - description: Domain from Return-Path header - example: "example.com" - return_path_org_domain: - type: string - description: Organizational domain extracted from Return-Path header (using Public Suffix List) - example: "example.com" - dkim_domains: - type: array - items: - $ref: '#/components/schemas/DKIMDomainInfo' - description: Domains from DKIM signatures with their organizational domains - aligned: - type: boolean - description: Whether all domains align (strict alignment - exact match) - example: true - relaxed_aligned: - type: boolean - description: Whether domains satisfy relaxed alignment (organizational domain match) - example: true - issues: - type: array - items: - type: string - description: Alignment issues - example: ["Return-Path domain does not match From domain"] - + $ref: './schemas.yaml#/components/schemas/DomainAlignment' 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" - + $ref: './schemas.yaml#/components/schemas/HeaderIssue' AuthenticationResults: - type: object - properties: - spf: - $ref: '#/components/schemas/AuthResult' - dkim: - type: array - items: - $ref: '#/components/schemas/AuthResult' - dmarc: - $ref: '#/components/schemas/AuthResult' - bimi: - $ref: '#/components/schemas/AuthResult' - arc: - $ref: '#/components/schemas/ARCResult' - iprev: - $ref: '#/components/schemas/IPRevResult' - x_google_dkim: - $ref: '#/components/schemas/AuthResult' - description: Google-specific DKIM authentication result (x-google-dkim) - x_aligned_from: - $ref: '#/components/schemas/AuthResult' - description: X-Aligned-From authentication result (checks address alignment) - + $ref: './schemas.yaml#/components/schemas/AuthenticationResults' AuthResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] - description: Authentication result - example: "pass" - domain: - type: string - description: Domain being authenticated - example: "example.com" - selector: - type: string - description: DKIM selector (for DKIM only) - example: "default" - details: - type: string - description: Additional details about the result - + $ref: './schemas.yaml#/components/schemas/AuthResult' ARCResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, none] - description: Overall ARC chain validation result - example: "pass" - chain_valid: - type: boolean - description: Whether the ARC chain signatures are valid - example: true - chain_length: - type: integer - description: Number of ARC sets in the chain - example: 2 - details: - type: string - description: Additional details about ARC validation - example: "ARC chain valid with 2 intermediaries" - + $ref: './schemas.yaml#/components/schemas/ARCResult' IPRevResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, temperror, permerror] - description: IP reverse DNS lookup result - example: "pass" - ip: - type: string - description: IP address that was checked - example: "195.110.101.58" - hostname: - type: string - description: Hostname from reverse DNS lookup (PTR record) - example: "authsmtp74.register.it" - details: - type: string - description: Additional details about the IP reverse lookup - example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)" - + $ref: './schemas.yaml#/components/schemas/IPRevResult' SpamAssassinResult: - type: object - required: - - score - - required_score - - is_spam - - test_details - properties: - deliverability_score: - type: integer - minimum: 0 - maximum: 100 - description: SpamAssassin deliverability score (0-100, higher is better) - example: 80 - deliverability_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade for SpamAssassin deliverability score - example: "B" - version: - type: string - description: SpamAssassin version - example: "SpamAssassin 4.0.1" - score: - type: number - format: float - description: SpamAssassin spam score - example: 2.3 - required_score: - type: number - format: float - description: Threshold for spam classification - example: 5.0 - is_spam: - type: boolean - description: Whether message is classified as spam - example: false - tests: - type: array - items: - type: string - description: List of triggered SpamAssassin tests - example: ["BAYES_00", "DKIM_SIGNED"] - test_details: - type: object - additionalProperties: - $ref: '#/components/schemas/SpamTestDetail' - description: Map of test names to their detailed results - example: - BAYES_00: - name: "BAYES_00" - score: -1.9 - description: "Bayes spam probability is 0 to 1%" - DKIM_SIGNED: - name: "DKIM_SIGNED" - score: 0.1 - description: "Message has a DKIM or DK signature, not necessarily valid" - report: - type: string - description: Full SpamAssassin report - + $ref: './schemas.yaml#/components/schemas/SpamAssassinResult' SpamTestDetail: - type: object - required: - - name - - score - properties: - name: - type: string - description: Test name - example: "BAYES_00" - score: - type: number - format: float - description: Score contribution of this test - example: -1.9 - params: - type: string - description: Symbol parameters or options - example: "0.02" - description: - type: string - description: Human-readable description of what this test checks - example: "Bayes spam probability is 0 to 1%" - + $ref: './schemas.yaml#/components/schemas/SpamTestDetail' RspamdResult: - type: object - required: - - score - - threshold - - is_spam - - symbols - properties: - deliverability_score: - type: integer - minimum: 0 - maximum: 100 - description: rspamd deliverability score (0-100, higher is better) - example: 85 - deliverability_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade for rspamd deliverability score - example: "A" - score: - type: number - format: float - description: rspamd spam score - example: -3.91 - threshold: - type: number - format: float - description: Score threshold for spam classification - example: 15.0 - action: - type: string - description: rspamd action (no action, add header, rewrite subject, soft reject, reject) - example: "no action" - is_spam: - type: boolean - description: Whether message is classified as spam (action is reject or soft reject) - example: false - server: - type: string - description: rspamd server that processed the message - example: "rspamd.example.com" - symbols: - type: object - additionalProperties: - $ref: '#/components/schemas/SpamTestDetail' - description: Map of triggered rspamd symbols to their details - example: - BAYES_HAM: - name: "BAYES_HAM" - score: -1.9 - params: "0.02" - report: - type: string - description: Full rspamd report (raw X-Spamd-Result header) - - + $ref: './schemas.yaml#/components/schemas/RspamdResult' DNSResults: - type: object - required: - - from_domain - properties: - from_domain: - type: string - description: From Domain name - example: "example.com" - rp_domain: - type: string - description: Return Path Domain name - example: "example.com" - from_mx_records: - type: array - items: - $ref: '#/components/schemas/MXRecord' - description: MX records for the From domain - rp_mx_records: - type: array - items: - $ref: '#/components/schemas/MXRecord' - description: MX records for the Return-Path domain - spf_records: - type: array - items: - $ref: '#/components/schemas/SPFRecord' - description: SPF records found (includes resolved include directives) - dkim_records: - type: array - items: - $ref: '#/components/schemas/DKIMRecord' - description: DKIM records found - dmarc_record: - $ref: '#/components/schemas/DMARCRecord' - bimi_record: - $ref: '#/components/schemas/BIMIRecord' - ptr_records: - type: array - items: - type: string - description: PTR (reverse DNS) records for the sender IP address - example: ["mail.example.com", "smtp.example.com"] - ptr_forward_records: - type: array - items: - type: string - description: A or AAAA records resolved from the PTR hostnames (forward confirmation) - example: ["192.0.2.1", "2001:db8::1"] - errors: - type: array - items: - type: string - description: DNS lookup errors - + $ref: './schemas.yaml#/components/schemas/DNSResults' MXRecord: - type: object - required: - - host - - priority - - valid - properties: - host: - type: string - description: MX hostname - example: "mail.example.com" - priority: - type: integer - format: uint16 - description: MX priority (lower is higher priority) - example: 10 - valid: - type: boolean - description: Whether the MX record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "Failed to lookup MX records" - + $ref: './schemas.yaml#/components/schemas/MXRecord' SPFRecord: - type: object - required: - - valid - properties: - domain: - type: string - description: Domain this SPF record belongs to - example: "example.com" - record: - type: string - description: SPF record content - example: "v=spf1 include:_spf.example.com ~all" - valid: - type: boolean - description: Whether the SPF record is valid - example: true - all_qualifier: - type: string - enum: ["+", "-", "~", "?"] - description: "Qualifier for the 'all' mechanism: + (pass), - (fail), ~ (softfail), ? (neutral)" - example: "~" - error: - type: string - description: Error message if validation failed - example: "No SPF record found" - + $ref: './schemas.yaml#/components/schemas/SPFRecord' DKIMRecord: - type: object - required: - - selector - - domain - - valid - properties: - selector: - type: string - description: DKIM selector - example: "default" - domain: - type: string - description: Domain name - example: "example.com" - record: - type: string - description: DKIM record content - example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." - valid: - type: boolean - description: Whether the DKIM record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No DKIM record found" - + $ref: './schemas.yaml#/components/schemas/DKIMRecord' DMARCRecord: - type: object - required: - - valid - properties: - record: - type: string - description: DMARC record content - example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" - policy: - type: string - enum: [none, quarantine, reject, unknown] - description: DMARC policy - example: "quarantine" - subdomain_policy: - type: string - enum: [none, quarantine, reject, unknown] - description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy - example: "quarantine" - percentage: - type: integer - minimum: 0 - maximum: 100 - description: Percentage of messages subjected to filtering (pct tag, default 100) - example: 100 - spf_alignment: - type: string - enum: [relaxed, strict] - description: SPF alignment mode (aspf tag) - example: "relaxed" - dkim_alignment: - type: string - enum: [relaxed, strict] - description: DKIM alignment mode (adkim tag) - example: "relaxed" - valid: - type: boolean - description: Whether the DMARC record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No DMARC record found" - + $ref: './schemas.yaml#/components/schemas/DMARCRecord' BIMIRecord: - type: object - required: - - selector - - domain - - valid - properties: - selector: - type: string - description: BIMI selector - example: "default" - domain: - type: string - description: Domain name - example: "example.com" - record: - type: string - description: BIMI record content - example: "v=BIMI1; l=https://example.com/logo.svg" - logo_url: - type: string - format: uri - description: URL to the brand logo (SVG) - example: "https://example.com/logo.svg" - vmc_url: - type: string - format: uri - description: URL to Verified Mark Certificate (optional) - example: "https://example.com/vmc.pem" - valid: - type: boolean - description: Whether the BIMI record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No BIMI record found" - + $ref: './schemas.yaml#/components/schemas/BIMIRecord' BlacklistCheck: - type: object - required: - - rbl - - listed - properties: - rbl: - type: string - description: RBL/DNSBL name - example: "zen.spamhaus.org" - listed: - type: boolean - description: Whether IP is listed - example: false - response: - type: string - description: RBL response code or message - example: "127.0.0.2" - error: - type: string - description: RBL error if any - + $ref: './schemas.yaml#/components/schemas/BlacklistCheck' Status: - type: object - required: - - status - - version - properties: - status: - type: string - enum: [healthy, degraded, unhealthy] - description: Overall service status - example: "healthy" - version: - type: string - description: Service version - example: "0.1.0-dev" - components: - type: object - properties: - database: - type: string - enum: [up, down] - example: "up" - mta: - type: string - enum: [up, down] - example: "up" - uptime: - type: integer - description: Service uptime in seconds - example: 3600 - + $ref: './schemas.yaml#/components/schemas/Status' Error: - type: object - required: - - error - - message - properties: - error: - type: string - description: Error code - example: "not_found" - message: - type: string - description: Human-readable error message - example: "Test not found" - details: - type: string - description: Additional error details - + $ref: './schemas.yaml#/components/schemas/Error' DomainTestRequest: - type: object - required: - - domain - properties: - domain: - type: string - pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' - description: Domain name to test (e.g., example.com) - example: "example.com" - + $ref: './schemas.yaml#/components/schemas/DomainTestRequest' DomainTestResponse: - type: object - required: - - domain - - score - - grade - - dns_results - properties: - domain: - type: string - description: The tested domain name - example: "example.com" - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall domain configuration score (0-100) - example: 85 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score - example: "A" - dns_results: - $ref: '#/components/schemas/DNSResults' - + $ref: './schemas.yaml#/components/schemas/DomainTestResponse' BlacklistCheckRequest: - type: object - required: - - ip - properties: - ip: - type: string - description: IPv4 or IPv6 address to check against blacklists - example: "192.0.2.1" - pattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,6}:([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$' - + $ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest' BlacklistCheckResponse: - type: object - required: - - ip - - blacklists - - listed_count - - score - - grade - properties: - ip: - type: string - description: The IP address that was checked - example: "192.0.2.1" - blacklists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: List of blacklist check results - listed_count: - type: integer - description: Number of blacklists that have this IP listed - example: 0 - score: - type: integer - minimum: 0 - maximum: 100 - description: Blacklist score (0-100, higher is better) - example: 100 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score - example: "A+" - whitelists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: List of DNS whitelist check results (informational only) - + $ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse' TestSummary: - type: object - required: - - test_id - - score - - grade - - created_at - properties: - test_id: - type: string - pattern: '^[a-z0-9-]+$' - description: Test identifier (base32-encoded with hyphens) - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall deliverability score (0-100) - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade - from_domain: - type: string - description: Sender domain extracted from the report - created_at: - type: string - format: date-time - + $ref: './schemas.yaml#/components/schemas/TestSummary' TestListResponse: - type: object - required: - - tests - - total - - offset - - limit - properties: - tests: - type: array - items: - $ref: '#/components/schemas/TestSummary' - total: - type: integer - description: Total number of tests - offset: - type: integer - description: Current offset - limit: - type: integer - description: Current limit + $ref: './schemas.yaml#/components/schemas/TestListResponse' diff --git a/api/schemas.yaml b/api/schemas.yaml new file mode 100644 index 0000000..df0b416 --- /dev/null +++ b/api/schemas.yaml @@ -0,0 +1,1173 @@ +openapi: 3.0.3 +info: + title: happyDeliver Schemas + description: Shared schema definitions for happyDeliver + version: 0.1.0 + +paths: {} + +components: + schemas: + Test: + type: object + required: + - id + - email + - status + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Unique test identifier (base32-encoded with hyphens) + example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" + email: + type: string + format: email + description: Unique test email address + example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" + status: + type: string + enum: [pending, analyzed] + description: Current test status (pending = no report yet, analyzed = report available) + example: "analyzed" + + TestResponse: + type: object + required: + - id + - email + - status + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Unique test identifier (base32-encoded with hyphens) + example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" + email: + type: string + format: email + example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" + status: + type: string + enum: [pending] + example: "pending" + message: + type: string + example: "Send your test email to the address above" + + Report: + type: object + required: + - id + - test_id + - score + - grade + - created_at + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Report identifier (base32-encoded with hyphens) + test_id: + type: string + pattern: '^[a-z0-9-]+$' + description: Associated test ID (base32-encoded with hyphens) + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall deliverability score as percentage (0-100) + example: 85 + 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" + summary: + $ref: '#/components/schemas/ScoreSummary' + authentication: + $ref: '#/components/schemas/AuthenticationResults' + spamassassin: + $ref: '#/components/schemas/SpamAssassinResult' + rspamd: + $ref: '#/components/schemas/RspamdResult' + dns_results: + $ref: '#/components/schemas/DNSResults' + blacklists: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: Map of IP addresses to their blacklist check results (array of checks per IP) + example: + "192.0.2.1": + - rbl: "zen.spamhaus.org" + 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: + $ref: '#/components/schemas/HeaderAnalysis' + raw_headers: + type: string + description: Raw email headers + created_at: + type: string + format: date-time + + ScoreSummary: + type: object + required: + - dns_score + - dns_grade + - authentication_score + - authentication_grade + - spam_score + - spam_grade + - blacklist_score + - blacklist_grade + - header_score + - header_grade + - content_score + - content_grade + properties: + dns_score: + type: integer + minimum: 0 + maximum: 100 + description: DNS records score (in percentage) + example: 42 + dns_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" + authentication_score: + type: integer + minimum: 0 + maximum: 100 + description: SPF/DKIM/DMARC score (in percentage) + example: 28 + authentication_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" + spam_score: + type: integer + minimum: 0 + maximum: 100 + description: Spam filter score (SpamAssassin + rspamd combined, in percentage) + example: 15 + spam_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" + blacklist_score: + type: integer + minimum: 0 + maximum: 100 + description: Blacklist check score (in percentage) + example: 20 + blacklist_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" + header_score: + type: integer + minimum: 0 + maximum: 100 + description: Header quality score (in percentage) + example: 9 + header_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" + content_score: + type: integer + minimum: 0 + maximum: 100 + description: Content quality score (in percentage) + example: 18 + content_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" + + 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: + - type + - severity + - message + properties: + type: + type: string + enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link, dangerous_html] + description: Type of content issue + example: "missing_alt" + severity: + type: string + enum: [critical, high, medium, low, info] + description: Issue severity + 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: 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, newsletter] + 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 + ip: + type: string + description: IP address of the sending server (IPv4 or IPv6) + example: "192.0.2.1" + reverse: + type: string + description: Reverse DNS (PTR record) for the IP address + example: "mail.example.com" + + DKIMDomainInfo: + type: object + required: + - domain + - org_domain + properties: + domain: + type: string + description: DKIM signature domain + example: "mail.example.com" + org_domain: + type: string + description: Organizational domain extracted from DKIM domain (using Public Suffix List) + example: "example.com" + + DomainAlignment: + type: object + properties: + from_domain: + type: string + description: Domain from From header + example: "example.com" + from_org_domain: + type: string + description: Organizational domain extracted from From header (using Public Suffix List) + example: "example.com" + return_path_domain: + type: string + description: Domain from Return-Path header + example: "example.com" + return_path_org_domain: + type: string + description: Organizational domain extracted from Return-Path header (using Public Suffix List) + example: "example.com" + dkim_domains: + type: array + items: + $ref: '#/components/schemas/DKIMDomainInfo' + description: Domains from DKIM signatures with their organizational domains + aligned: + type: boolean + description: Whether all domains align (strict alignment - exact match) + example: true + relaxed_aligned: + type: boolean + description: Whether domains satisfy relaxed alignment (organizational domain match) + 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 + properties: + spf: + $ref: '#/components/schemas/AuthResult' + dkim: + type: array + items: + $ref: '#/components/schemas/AuthResult' + dmarc: + $ref: '#/components/schemas/AuthResult' + bimi: + $ref: '#/components/schemas/AuthResult' + arc: + $ref: '#/components/schemas/ARCResult' + iprev: + $ref: '#/components/schemas/IPRevResult' + x_google_dkim: + $ref: '#/components/schemas/AuthResult' + description: Google-specific DKIM authentication result (x-google-dkim) + x_aligned_from: + $ref: '#/components/schemas/AuthResult' + description: X-Aligned-From authentication result (checks address alignment) + + AuthResult: + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] + description: Authentication result + example: "pass" + domain: + type: string + description: Domain being authenticated + example: "example.com" + selector: + type: string + description: DKIM selector (for DKIM only) + example: "default" + details: + type: string + description: Additional details about the result + + ARCResult: + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, none] + description: Overall ARC chain validation result + example: "pass" + chain_valid: + type: boolean + description: Whether the ARC chain signatures are valid + example: true + chain_length: + type: integer + description: Number of ARC sets in the chain + example: 2 + details: + type: string + description: Additional details about ARC validation + example: "ARC chain valid with 2 intermediaries" + + IPRevResult: + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, temperror, permerror] + description: IP reverse DNS lookup result + example: "pass" + ip: + type: string + description: IP address that was checked + example: "195.110.101.58" + hostname: + type: string + description: Hostname from reverse DNS lookup (PTR record) + example: "authsmtp74.register.it" + details: + type: string + description: Additional details about the IP reverse lookup + example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)" + + SpamAssassinResult: + type: object + required: + - score + - required_score + - is_spam + - test_details + properties: + deliverability_score: + type: integer + minimum: 0 + maximum: 100 + description: SpamAssassin deliverability score (0-100, higher is better) + example: 80 + deliverability_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade for SpamAssassin deliverability score + example: "B" + version: + type: string + description: SpamAssassin version + example: "SpamAssassin 4.0.1" + score: + type: number + format: float + description: SpamAssassin spam score + example: 2.3 + required_score: + type: number + format: float + description: Threshold for spam classification + example: 5.0 + is_spam: + type: boolean + description: Whether message is classified as spam + example: false + tests: + type: array + items: + type: string + description: List of triggered SpamAssassin tests + example: ["BAYES_00", "DKIM_SIGNED"] + test_details: + type: object + additionalProperties: + $ref: '#/components/schemas/SpamTestDetail' + description: Map of test names to their detailed results + example: + BAYES_00: + name: "BAYES_00" + score: -1.9 + description: "Bayes spam probability is 0 to 1%" + DKIM_SIGNED: + name: "DKIM_SIGNED" + score: 0.1 + description: "Message has a DKIM or DK signature, not necessarily valid" + report: + type: string + description: Full SpamAssassin report + + SpamTestDetail: + type: object + required: + - name + - score + properties: + name: + type: string + description: Test name + example: "BAYES_00" + score: + type: number + format: float + description: Score contribution of this test + example: -1.9 + params: + type: string + description: Symbol parameters or options + example: "0.02" + description: + type: string + description: Human-readable description of what this test checks + example: "Bayes spam probability is 0 to 1%" + + RspamdResult: + type: object + required: + - score + - threshold + - is_spam + - symbols + properties: + deliverability_score: + type: integer + minimum: 0 + maximum: 100 + description: rspamd deliverability score (0-100, higher is better) + example: 85 + deliverability_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade for rspamd deliverability score + example: "A" + score: + type: number + format: float + description: rspamd spam score + example: -3.91 + threshold: + type: number + format: float + description: Score threshold for spam classification + example: 15.0 + action: + type: string + description: rspamd action (no action, add header, rewrite subject, soft reject, reject) + example: "no action" + is_spam: + type: boolean + description: Whether message is classified as spam (action is reject or soft reject) + example: false + server: + type: string + description: rspamd server that processed the message + example: "rspamd.example.com" + symbols: + type: object + additionalProperties: + $ref: '#/components/schemas/SpamTestDetail' + description: Map of triggered rspamd symbols to their details + example: + BAYES_HAM: + name: "BAYES_HAM" + score: -1.9 + params: "0.02" + report: + type: string + description: Full rspamd report (raw X-Spamd-Result header) + + + DNSResults: + type: object + required: + - from_domain + properties: + from_domain: + type: string + description: From Domain name + example: "example.com" + rp_domain: + type: string + description: Return Path Domain name + example: "example.com" + from_mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the From domain + rp_mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the Return-Path domain + spf_records: + type: array + items: + $ref: '#/components/schemas/SPFRecord' + description: SPF records found (includes resolved include directives) + dkim_records: + type: array + items: + $ref: '#/components/schemas/DKIMRecord' + description: DKIM records found + dmarc_record: + $ref: '#/components/schemas/DMARCRecord' + bimi_record: + $ref: '#/components/schemas/BIMIRecord' + ptr_records: + type: array + items: + type: string + description: PTR (reverse DNS) records for the sender IP address + example: ["mail.example.com", "smtp.example.com"] + ptr_forward_records: + type: array + items: + type: string + description: A or AAAA records resolved from the PTR hostnames (forward confirmation) + example: ["192.0.2.1", "2001:db8::1"] + errors: + type: array + items: + type: string + description: DNS lookup errors + + MXRecord: + type: object + required: + - host + - priority + - valid + properties: + host: + type: string + description: MX hostname + example: "mail.example.com" + priority: + type: integer + format: uint16 + description: MX priority (lower is higher priority) + example: 10 + valid: + type: boolean + description: Whether the MX record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "Failed to lookup MX records" + + SPFRecord: + type: object + required: + - valid + properties: + domain: + type: string + description: Domain this SPF record belongs to + example: "example.com" + record: + type: string + description: SPF record content + example: "v=spf1 include:_spf.example.com ~all" + valid: + type: boolean + description: Whether the SPF record is valid + example: true + all_qualifier: + type: string + enum: ["+", "-", "~", "?"] + description: "Qualifier for the 'all' mechanism: + (pass), - (fail), ~ (softfail), ? (neutral)" + example: "~" + error: + type: string + description: Error message if validation failed + example: "No SPF record found" + + DKIMRecord: + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: DKIM selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: DKIM record content + example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." + valid: + type: boolean + description: Whether the DKIM record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DKIM record found" + + DMARCRecord: + type: object + required: + - valid + properties: + record: + type: string + description: DMARC record content + example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" + policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC policy + example: "quarantine" + subdomain_policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy + example: "quarantine" + percentage: + type: integer + minimum: 0 + maximum: 100 + description: Percentage of messages subjected to filtering (pct tag, default 100) + example: 100 + spf_alignment: + type: string + enum: [relaxed, strict] + description: SPF alignment mode (aspf tag) + example: "relaxed" + dkim_alignment: + type: string + enum: [relaxed, strict] + description: DKIM alignment mode (adkim tag) + example: "relaxed" + valid: + type: boolean + description: Whether the DMARC record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DMARC record found" + + BIMIRecord: + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: BIMI selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: BIMI record content + example: "v=BIMI1; l=https://example.com/logo.svg" + logo_url: + type: string + format: uri + description: URL to the brand logo (SVG) + example: "https://example.com/logo.svg" + vmc_url: + type: string + format: uri + description: URL to Verified Mark Certificate (optional) + example: "https://example.com/vmc.pem" + valid: + type: boolean + description: Whether the BIMI record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No BIMI record found" + + BlacklistCheck: + type: object + required: + - rbl + - listed + properties: + rbl: + type: string + description: RBL/DNSBL name + example: "zen.spamhaus.org" + listed: + type: boolean + description: Whether IP is listed + example: false + response: + type: string + description: RBL response code or message + example: "127.0.0.2" + error: + type: string + description: RBL error if any + + Status: + type: object + required: + - status + - version + properties: + status: + type: string + enum: [healthy, degraded, unhealthy] + description: Overall service status + example: "healthy" + version: + type: string + description: Service version + example: "0.1.0-dev" + components: + type: object + properties: + database: + type: string + enum: [up, down] + example: "up" + mta: + type: string + enum: [up, down] + example: "up" + uptime: + type: integer + description: Service uptime in seconds + example: 3600 + + Error: + type: object + required: + - error + - message + properties: + error: + type: string + description: Error code + example: "not_found" + message: + type: string + description: Human-readable error message + example: "Test not found" + details: + type: string + description: Additional error details + + DomainTestRequest: + type: object + required: + - domain + properties: + domain: + type: string + pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' + description: Domain name to test (e.g., example.com) + example: "example.com" + + DomainTestResponse: + type: object + required: + - domain + - score + - grade + - dns_results + properties: + domain: + type: string + description: The tested domain name + example: "example.com" + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall domain configuration score (0-100) + example: 85 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score + example: "A" + dns_results: + $ref: '#/components/schemas/DNSResults' + + BlacklistCheckRequest: + type: object + required: + - ip + properties: + ip: + type: string + description: IPv4 or IPv6 address to check against blacklists + example: "192.0.2.1" + pattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,6}:([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$' + + BlacklistCheckResponse: + type: object + required: + - ip + - blacklists + - listed_count + - score + - grade + properties: + ip: + type: string + description: The IP address that was checked + example: "192.0.2.1" + blacklists: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: List of blacklist check results + listed_count: + type: integer + description: Number of blacklists that have this IP listed + example: 0 + score: + type: integer + minimum: 0 + maximum: 100 + description: Blacklist score (0-100, higher is better) + example: 100 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score + example: "A+" + whitelists: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: List of DNS whitelist check results (informational only) + + TestSummary: + type: object + required: + - test_id + - score + - grade + - created_at + properties: + test_id: + type: string + pattern: '^[a-z0-9-]+$' + description: Test identifier (base32-encoded with hyphens) + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall deliverability score (0-100) + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade + from_domain: + type: string + description: Sender domain extracted from the report + created_at: + type: string + format: date-time + + TestListResponse: + type: object + required: + - tests + - total + - offset + - limit + properties: + tests: + type: array + items: + $ref: '#/components/schemas/TestSummary' + total: + type: integer + description: Total number of tests + offset: + type: integer + description: Current offset + limit: + type: integer + description: Current limit diff --git a/generate.go b/generate.go index d1ee5ab..324c52c 100644 --- a/generate.go +++ b/generate.go @@ -21,5 +21,5 @@ package main -//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml +//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml //go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml diff --git a/internal/api/handlers.go b/internal/api/handlers.go index e524b40..de2d5df 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -31,6 +31,7 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/storage" "git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/version" @@ -40,8 +41,8 @@ import ( // This interface breaks the circular dependency with pkg/analyzer type EmailAnalyzer interface { AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error) - AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string) - CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error) + AnalyzeDomain(domain string) (dnsResults *model.DNSResults, score int, grade string) + CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.BlacklistCheck, listedCount int, score int, grade string, err error) } // APIHandler implements the ServerInterface for handling API requests @@ -79,11 +80,11 @@ func (h *APIHandler) CreateTest(c *gin.Context) { ) // Return response - c.JSON(http.StatusCreated, TestResponse{ + c.JSON(http.StatusCreated, model.TestResponse{ Id: base32ID, Email: openapi_types.Email(email), - Status: TestResponseStatusPending, - Message: stringPtr("Send your test email to the given address"), + Status: model.TestResponseStatusPending, + Message: utils.PtrTo("Send your test email to the given address"), }) } @@ -93,10 +94,10 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -104,20 +105,20 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { // Check if a report exists for this test ID reportExists, err := h.storage.ReportExists(testUUID) if err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to check test status", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } // Determine status based on report existence - var apiStatus TestStatus + var apiStatus model.TestStatus if reportExists { - apiStatus = TestStatusAnalyzed + apiStatus = model.TestStatusAnalyzed } else { - apiStatus = TestStatusPending + apiStatus = model.TestStatusPending } // Generate test email address using Base32-encoded UUID @@ -127,7 +128,7 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { h.config.Email.Domain, ) - c.JSON(http.StatusOK, Test{ + c.JSON(http.StatusOK, model.Test{ Id: id, Email: openapi_types.Email(email), Status: apiStatus, @@ -140,10 +141,10 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -151,16 +152,16 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) { reportJSON, _, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ + c.JSON(http.StatusNotFound, model.Error{ Error: "not_found", Message: "Report not found", }) return } - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to retrieve report", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -175,10 +176,10 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -186,16 +187,16 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { _, rawEmail, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ + c.JSON(http.StatusNotFound, model.Error{ Error: "not_found", Message: "Email not found", }) return } - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to retrieve raw email", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -209,10 +210,10 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -221,16 +222,16 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { _, rawEmail, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ + c.JSON(http.StatusNotFound, model.Error{ Error: "not_found", Message: "Email not found", }) return } - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to retrieve email", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -238,20 +239,20 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { // Re-analyze the email using the current analyzer reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID) if err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "analysis_error", Message: "Failed to re-analyze email", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } // Update the report in storage if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to update report", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -267,24 +268,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) { uptime := int(time.Since(h.startTime).Seconds()) // Check database connectivity by trying to check if a report exists - dbStatus := StatusComponentsDatabaseUp + dbStatus := model.StatusComponentsDatabaseUp if _, err := h.storage.ReportExists(uuid.New()); err != nil { - dbStatus = StatusComponentsDatabaseDown + dbStatus = model.StatusComponentsDatabaseDown } // Determine overall status - overallStatus := Healthy - if dbStatus == StatusComponentsDatabaseDown { - overallStatus = Unhealthy + overallStatus := model.Healthy + if dbStatus == model.StatusComponentsDatabaseDown { + overallStatus = model.Unhealthy } - mtaStatus := StatusComponentsMtaUp - c.JSON(http.StatusOK, Status{ + mtaStatus := model.StatusComponentsMtaUp + c.JSON(http.StatusOK, model.Status{ Status: overallStatus, Version: version.Version, Components: &struct { - Database *StatusComponentsDatabase `json:"database,omitempty"` - Mta *StatusComponentsMta `json:"mta,omitempty"` + Database *model.StatusComponentsDatabase `json:"database,omitempty"` + Mta *model.StatusComponentsMta `json:"mta,omitempty"` }{ Database: &dbStatus, Mta: &mtaStatus, @@ -296,14 +297,14 @@ func (h *APIHandler) GetStatus(c *gin.Context) { // TestDomain performs synchronous domain analysis // (POST /domain) func (h *APIHandler) TestDomain(c *gin.Context) { - var request DomainTestRequest + var request model.DomainTestRequest // Bind and validate request if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_request", Message: "Invalid request body", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -312,28 +313,28 @@ func (h *APIHandler) TestDomain(c *gin.Context) { dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain) // Convert grade string to DomainTestResponseGrade enum - var responseGrade DomainTestResponseGrade + var responseGrade model.DomainTestResponseGrade switch grade { case "A+": - responseGrade = DomainTestResponseGradeA + responseGrade = model.DomainTestResponseGradeA case "A": - responseGrade = DomainTestResponseGradeA1 + responseGrade = model.DomainTestResponseGradeA1 case "B": - responseGrade = DomainTestResponseGradeB + responseGrade = model.DomainTestResponseGradeB case "C": - responseGrade = DomainTestResponseGradeC + responseGrade = model.DomainTestResponseGradeC case "D": - responseGrade = DomainTestResponseGradeD + responseGrade = model.DomainTestResponseGradeD case "E": - responseGrade = DomainTestResponseGradeE + responseGrade = model.DomainTestResponseGradeE case "F": - responseGrade = DomainTestResponseGradeF + responseGrade = model.DomainTestResponseGradeF default: - responseGrade = DomainTestResponseGradeF + responseGrade = model.DomainTestResponseGradeF } // Build response - response := DomainTestResponse{ + response := model.DomainTestResponse{ Domain: request.Domain, Score: score, Grade: responseGrade, @@ -346,14 +347,14 @@ func (h *APIHandler) TestDomain(c *gin.Context) { // CheckBlacklist checks an IP address against DNS blacklists // (POST /blacklist) func (h *APIHandler) CheckBlacklist(c *gin.Context) { - var request BlacklistCheckRequest + var request model.BlacklistCheckRequest // Bind and validate request if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_request", Message: "Invalid request body", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -361,22 +362,22 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) { // Perform blacklist check using analyzer checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_ip", Message: "Invalid IP address", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } // Build response - response := BlacklistCheckResponse{ + response := model.BlacklistCheckResponse{ Ip: request.Ip, Blacklists: checks, Whitelists: &whitelists, ListedCount: listedCount, Score: score, - Grade: BlacklistCheckResponseGrade(grade), + Grade: model.BlacklistCheckResponseGrade(grade), } c.JSON(http.StatusOK, response) @@ -386,7 +387,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) { // (GET /tests) func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { if h.config.DisableTestList { - c.JSON(http.StatusForbidden, Error{ + c.JSON(http.StatusForbidden, model.Error{ Error: "feature_disabled", Message: "Test listing is disabled on this instance", }) @@ -405,51 +406,17 @@ func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { } } - summaries, total, err := h.storage.ListReportSummaries(offset, limit) + tests, total, err := h.storage.ListReportSummaries(offset, limit) if err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to list tests", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } - tests := make([]TestSummary, 0, len(summaries)) - for _, s := range summaries { - base32ID := utils.UUIDToBase32(s.TestID) - - var grade TestSummaryGrade - switch s.Grade { - case "A+": - grade = TestSummaryGradeA - case "A": - grade = TestSummaryGradeA1 - case "B": - grade = TestSummaryGradeB - case "C": - grade = TestSummaryGradeC - case "D": - grade = TestSummaryGradeD - case "E": - grade = TestSummaryGradeE - default: - grade = TestSummaryGradeF - } - - summary := TestSummary{ - TestId: base32ID, - Score: s.Score, - Grade: grade, - CreatedAt: s.CreatedAt, - } - if s.FromDomain != "" { - summary.FromDomain = stringPtr(s.FromDomain) - } - tests = append(tests, summary) - } - - c.JSON(http.StatusOK, TestListResponse{ + c.JSON(http.StatusOK, model.TestListResponse{ Tests: tests, Total: int(total), Offset: offset, diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 1077e74..86605df 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -30,6 +30,9 @@ import ( "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) var ( @@ -45,21 +48,12 @@ type Storage interface { ReportExists(testID uuid.UUID) (bool, error) UpdateReport(testID uuid.UUID, reportJSON []byte) error DeleteOldReports(olderThan time.Time) (int64, error) - ListReportSummaries(offset, limit int) ([]ReportSummary, int64, error) + ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) // Close closes the database connection Close() error } -// ReportSummary is a lightweight projection of Report for listing -type ReportSummary struct { - TestID uuid.UUID - Score int - Grade string - FromDomain string - CreatedAt time.Time -} - // DBStorage implements Storage using GORM type DBStorage struct { db *gorm.DB @@ -149,15 +143,24 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { return result.RowsAffected, nil } +// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary +type reportSummaryRow struct { + TestID uuid.UUID + Score int + Grade string + FromDomain string + CreatedAt time.Time +} + // ListReportSummaries returns a paginated list of lightweight report summaries -func (s *DBStorage) ListReportSummaries(offset, limit int) ([]ReportSummary, int64, error) { +func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) { var total int64 if err := s.db.Model(&Report{}).Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count reports: %w", err) } if total == 0 { - return []ReportSummary{}, 0, nil + return []model.TestSummary{}, 0, nil } var selectExpr string @@ -168,25 +171,41 @@ func (s *DBStorage) ListReportSummaries(offset, limit int) ([]ReportSummary, int `convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` + `convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` + `created_at` - default: // sqlite + case "sqlite": selectExpr = `test_id, ` + `json_extract(report_json, '$.score') as score, ` + `json_extract(report_json, '$.grade') as grade, ` + `json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` + `created_at` + default: + return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect") } - var summaries []ReportSummary + var rows []reportSummaryRow err := s.db.Model(&Report{}). Select(selectExpr). Order("created_at DESC"). Offset(offset). Limit(limit). - Scan(&summaries).Error + Scan(&rows).Error if err != nil { return nil, 0, fmt.Errorf("failed to list report summaries: %w", err) } + summaries := make([]model.TestSummary, 0, len(rows)) + for _, r := range rows { + s := model.TestSummary{ + TestId: utils.UUIDToBase32(r.TestID), + Score: r.Score, + Grade: model.TestSummaryGrade(r.Grade), + CreatedAt: r.CreatedAt, + } + if r.FromDomain != "" { + s.FromDomain = utils.PtrTo(r.FromDomain) + } + summaries = append(summaries, s) + } + return summaries, total, nil } diff --git a/internal/api/helpers.go b/internal/utils/ptr.go similarity index 91% rename from internal/api/helpers.go rename to internal/utils/ptr.go index cce306a..748d6ba 100644 --- a/internal/api/helpers.go +++ b/internal/utils/ptr.go @@ -1,5 +1,5 @@ // This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain +// Copyright (c) 2026 happyDomain // Authors: Pierre-Olivier Mercier, et al. // // This program is offered under a commercial and under the AGPL license. @@ -19,11 +19,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package api - -func stringPtr(s string) *string { - return &s -} +package utils // PtrTo returns a pointer to the provided value func PtrTo[T any](v T) *T { diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index f21d1f8..5f57df3 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -28,7 +28,7 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/config" ) @@ -59,7 +59,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { type AnalysisResult struct { Email *EmailMessage Results *AnalysisResults - Report *api.Report + Report *model.Report } // AnalyzeEmailBytes performs complete email analysis from raw bytes @@ -113,7 +113,7 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt } // AnalyzeDomain performs DNS analysis for a domain and returns the results -func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) { +func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) { // Perform DNS analysis dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain) @@ -124,7 +124,7 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) } // CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists -func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) { +func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.BlacklistCheck, int, int, string, error) { // Check the IP against all configured RBLs checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip) if err != nil { @@ -134,7 +134,7 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.Bl // Calculate score using the existing function // Create a minimal RBLResults structure for scoring results := &DNSListResults{ - Checks: map[string][]api.BlacklistCheck{ip: checks}, + Checks: map[string][]model.BlacklistCheck{ip: checks}, IPsChecked: []string{ip}, ListedCount: listedCount, } diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 2beeb1f..da31b1c 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -24,7 +24,7 @@ package analyzer import ( "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) // AuthenticationAnalyzer analyzes email authentication results @@ -38,8 +38,8 @@ func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer } // AnalyzeAuthentication extracts and analyzes authentication results from email headers -func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults { - results := &api.AuthenticationResults{} +func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults { + results := &model.AuthenticationResults{} // Parse Authentication-Results headers authHeaders := email.GetAuthenticationResults(a.receiverHostname) @@ -65,7 +65,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api // parseAuthenticationResultsHeader parses an Authentication-Results header // Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com -func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) { +func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) { // Split by semicolon to get individual results parts := strings.Split(header, ";") if len(parts) < 2 { @@ -91,7 +91,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, dkimResult := a.parseDKIMResult(part) if dkimResult != nil { if results.Dkim == nil { - dkimList := []api.AuthResult{*dkimResult} + dkimList := []model.AuthResult{*dkimResult} results.Dkim = &dkimList } else { *results.Dkim = append(*results.Dkim, *dkimResult) @@ -145,7 +145,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, // 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, string) { +func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) { if results == nil { return 0, "" } diff --git a/pkg/analyzer/authentication_arc.go b/pkg/analyzer/authentication_arc.go index 01b7505..e7333ce 100644 --- a/pkg/analyzer/authentication_arc.go +++ b/pkg/analyzer/authentication_arc.go @@ -27,7 +27,8 @@ import ( "slices" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // textprotoCanonical converts a header name to canonical form @@ -52,24 +53,24 @@ func pluralize(count int) string { // parseARCResult parses ARC result from Authentication-Results // Example: arc=pass -func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { - result := &api.ARCResult{} +func (a *AuthenticationAnalyzer) parseARCResult(part string) *model.ARCResult { + result := &model.ARCResult{} // Extract result (pass, fail, none) re := regexp.MustCompile(`arc=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.ARCResultResult(resultStr) + result.Result = model.ARCResultResult(resultStr) } - result.Details = api.PtrTo(strings.TrimPrefix(part, "arc=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "arc=")) return result } // parseARCHeaders parses ARC headers from email message // ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal -func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult { +func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARCResult { // Get all ARC-related headers arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] @@ -80,8 +81,8 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe return nil } - result := &api.ARCResult{ - Result: api.ARCResultResultNone, + result := &model.ARCResult{ + Result: model.ARCResultResultNone, } // Count the ARC chain length (number of sets) @@ -94,15 +95,15 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe // Determine overall result if chainLength == 0 { - result.Result = api.ARCResultResultNone + result.Result = model.ARCResultResultNone details := "No ARC chain present" result.Details = &details } else if !chainValid { - result.Result = api.ARCResultResultFail + result.Result = model.ARCResultResultFail details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) result.Details = &details } else { - result.Result = api.ARCResultResultPass + result.Result = model.ARCResultResultPass details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) result.Details = &details } @@ -111,7 +112,7 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe } // enhanceARCResult enhances an existing ARC result with chain information -func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { +func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) { if arcResult == nil { return } diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go index 7f2f99e..ac51d0b 100644 --- a/pkg/analyzer/authentication_arc_test.go +++ b/pkg/analyzer/authentication_arc_test.go @@ -24,29 +24,29 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseARCResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.ARCResultResult + expectedResult model.ARCResultResult }{ { name: "ARC pass", part: "arc=pass", - expectedResult: api.ARCResultResultPass, + expectedResult: model.ARCResultResultPass, }, { name: "ARC fail", part: "arc=fail", - expectedResult: api.ARCResultResultFail, + expectedResult: model.ARCResultResultFail, }, { name: "ARC none", part: "arc=none", - expectedResult: api.ARCResultResultNone, + expectedResult: model.ARCResultResultNone, }, } diff --git a/pkg/analyzer/authentication_bimi.go b/pkg/analyzer/authentication_bimi.go index 0d68281..9654ac7 100644 --- a/pkg/analyzer/authentication_bimi.go +++ b/pkg/analyzer/authentication_bimi.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseBIMIResult parses BIMI result from Authentication-Results // Example: bimi=pass header.d=example.com header.selector=default -func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`bimi=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -54,17 +55,17 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { result.Selector = &selector } - result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "bimi=")) return result } -func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) { if results.Bimi != nil { switch results.Bimi.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: return 100 - case api.AuthResultResultDeclined: + case model.AuthResultResultDeclined: return 59 default: // fail return 0 diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go index 7cb9c85..440f356 100644 --- a/pkg/analyzer/authentication_bimi_test.go +++ b/pkg/analyzer/authentication_bimi_test.go @@ -24,42 +24,42 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseBIMIResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string expectedSelector string }{ { name: "BIMI pass with domain and selector", part: "bimi=pass header.d=example.com header.selector=default", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, { name: "BIMI fail", part: "bimi=fail header.d=example.com header.selector=default", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", expectedSelector: "default", }, { name: "BIMI with short form (d= and selector=)", part: "bimi=pass d=example.com selector=v1", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "v1", }, { name: "BIMI none", part: "bimi=none header.d=example.com", - expectedResult: api.AuthResultResultNone, + expectedResult: model.AuthResultResultNone, expectedDomain: "example.com", }, } diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go index b6cf5f8..4165d8b 100644 --- a/pkg/analyzer/authentication_dkim.go +++ b/pkg/analyzer/authentication_dkim.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseDKIMResult parses DKIM result from Authentication-Results // Example: dkim=pass header.d=example.com header.s=selector1 -func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`dkim=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -54,18 +55,18 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { result.Selector = &selector } - result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim=")) return result } -func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateDKIMScore(results *model.AuthenticationResults) (score int) { // Expect at least one passing signature if results.Dkim != nil && len(*results.Dkim) > 0 { hasPass := false hasNonPass := false for _, dkim := range *results.Dkim { - if dkim.Result == api.AuthResultResultPass { + if dkim.Result == model.AuthResultResultPass { hasPass = true } else { hasNonPass = true diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go index 3218639..0576854 100644 --- a/pkg/analyzer/authentication_dkim_test.go +++ b/pkg/analyzer/authentication_dkim_test.go @@ -24,35 +24,35 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseDKIMResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string expectedSelector string }{ { name: "DKIM pass with domain and selector", part: "dkim=pass header.d=example.com header.s=default", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, { name: "DKIM fail", part: "dkim=fail header.d=example.com header.s=selector1", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", expectedSelector: "selector1", }, { name: "DKIM with short form (d= and s=)", part: "dkim=pass d=example.com s=default", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, diff --git a/pkg/analyzer/authentication_dmarc.go b/pkg/analyzer/authentication_dmarc.go index 329a5c9..c89093d 100644 --- a/pkg/analyzer/authentication_dmarc.go +++ b/pkg/analyzer/authentication_dmarc.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseDMARCResult parses DMARC result from Authentication-Results // Example: dmarc=pass action=none header.from=example.com -func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`dmarc=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.from) @@ -47,17 +48,17 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { result.Domain = &domain } - result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "dmarc=")) return result } -func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) { if results.Dmarc != nil { switch results.Dmarc.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: return 100 - case api.AuthResultResultNone: + case model.AuthResultResultNone: return 33 default: // fail return 0 diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go index 3b8fb08..69779a7 100644 --- a/pkg/analyzer/authentication_dmarc_test.go +++ b/pkg/analyzer/authentication_dmarc_test.go @@ -24,26 +24,26 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseDMARCResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string }{ { name: "DMARC pass", part: "dmarc=pass action=none header.from=example.com", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", }, { name: "DMARC fail", part: "dmarc=fail action=quarantine header.from=example.com", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", }, } diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go index e799094..3ed045c 100644 --- a/pkg/analyzer/authentication_iprev.go +++ b/pkg/analyzer/authentication_iprev.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseIPRevResult parses IP reverse lookup result from Authentication-Results // Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it) -func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult { - result := &api.IPRevResult{} +func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResult { + result := &model.IPRevResult{} // Extract result (pass, fail, temperror, permerror, none) re := regexp.MustCompile(`iprev=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.IPRevResultResult(resultStr) + result.Result = model.IPRevResultResult(resultStr) } // Extract IP address (smtp.remote-ip or remote-ip) @@ -54,15 +55,15 @@ func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult result.Hostname = &hostname } - result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "iprev=")) return result } -func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) { if results.Iprev != nil { switch results.Iprev.Result { - case api.Pass: + case model.Pass: return 100 default: // fail, temperror, permerror return 0 diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go index 5b46995..55f85d5 100644 --- a/pkg/analyzer/authentication_iprev_test.go +++ b/pkg/analyzer/authentication_iprev_test.go @@ -24,71 +24,72 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestParseIPRevResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.IPRevResultResult + expectedResult model.IPRevResultResult expectedIP *string expectedHostname *string }{ { name: "IPRev pass with IP and hostname", part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("195.110.101.58"), - expectedHostname: api.PtrTo("authsmtp74.register.it"), + expectedResult: model.Pass, + expectedIP: utils.PtrTo("195.110.101.58"), + expectedHostname: utils.PtrTo("authsmtp74.register.it"), }, { name: "IPRev pass without smtp prefix", part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("mail.example.com"), + expectedResult: model.Pass, + expectedIP: utils.PtrTo("192.0.2.1"), + expectedHostname: utils.PtrTo("mail.example.com"), }, { name: "IPRev fail", part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)", - expectedResult: api.Fail, - expectedIP: api.PtrTo("198.51.100.42"), - expectedHostname: api.PtrTo("unknown.host.com"), + expectedResult: model.Fail, + expectedIP: utils.PtrTo("198.51.100.42"), + expectedHostname: utils.PtrTo("unknown.host.com"), }, { name: "IPRev temperror", part: "iprev=temperror smtp.remote-ip=203.0.113.1", - expectedResult: api.Temperror, - expectedIP: api.PtrTo("203.0.113.1"), + expectedResult: model.Temperror, + expectedIP: utils.PtrTo("203.0.113.1"), expectedHostname: nil, }, { name: "IPRev permerror", part: "iprev=permerror smtp.remote-ip=192.0.2.100", - expectedResult: api.Permerror, - expectedIP: api.PtrTo("192.0.2.100"), + expectedResult: model.Permerror, + expectedIP: utils.PtrTo("192.0.2.100"), expectedHostname: nil, }, { name: "IPRev with IPv6", part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("2001:db8::1"), - expectedHostname: api.PtrTo("ipv6.example.com"), + expectedResult: model.Pass, + expectedIP: utils.PtrTo("2001:db8::1"), + expectedHostname: utils.PtrTo("ipv6.example.com"), }, { name: "IPRev with subdomain hostname", part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.50"), - expectedHostname: api.PtrTo("mail.subdomain.example.com"), + expectedResult: model.Pass, + expectedIP: utils.PtrTo("192.0.2.50"), + expectedHostname: utils.PtrTo("mail.subdomain.example.com"), }, { name: "IPRev pass without parentheses", part: "iprev=pass smtp.remote-ip=192.0.2.200", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.200"), + expectedResult: model.Pass, + expectedIP: utils.PtrTo("192.0.2.200"), expectedHostname: nil, }, } @@ -142,29 +143,29 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { tests := []struct { name string header string - expectedIPRevResult *api.IPRevResultResult + expectedIPRevResult *model.IPRevResultResult expectedIP *string expectedHostname *string }{ { name: "IPRev pass in Authentication-Results", header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("195.110.101.58"), - expectedHostname: api.PtrTo("authsmtp74.register.it"), + expectedIPRevResult: utils.PtrTo(model.Pass), + expectedIP: utils.PtrTo("195.110.101.58"), + expectedHostname: utils.PtrTo("authsmtp74.register.it"), }, { name: "IPRev with other authentication methods", header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("mail.example.com"), + expectedIPRevResult: utils.PtrTo(model.Pass), + expectedIP: utils.PtrTo("192.0.2.1"), + expectedHostname: utils.PtrTo("mail.example.com"), }, { name: "IPRev fail", header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42", - expectedIPRevResult: api.PtrTo(api.Fail), - expectedIP: api.PtrTo("198.51.100.42"), + expectedIPRevResult: utils.PtrTo(model.Fail), + expectedIP: utils.PtrTo("198.51.100.42"), expectedHostname: nil, }, { @@ -175,9 +176,9 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { { name: "Multiple IPRev results - only first is parsed", header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("first.com"), + expectedIPRevResult: utils.PtrTo(model.Pass), + expectedIP: utils.PtrTo("192.0.2.1"), + expectedHostname: utils.PtrTo("first.com"), }, } @@ -185,7 +186,7 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(tt.header, results) // Check IPRev diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go index fc41e3c..1488c98 100644 --- a/pkg/analyzer/authentication_spf.go +++ b/pkg/analyzer/authentication_spf.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseSPFResult parses SPF result from Authentication-Results // Example: spf=pass smtp.mailfrom=sender@example.com -func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`spf=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain @@ -51,13 +52,13 @@ func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { } } - result.Details = api.PtrTo(strings.TrimPrefix(part, "spf=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "spf=")) return result } // parseLegacySPF attempts to parse SPF from Received-SPF header -func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { +func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.AuthResult { receivedSPF := email.Header.Get("Received-SPF") if receivedSPF == "" { return nil @@ -73,13 +74,13 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe } } - result := &api.AuthResult{} + result := &model.AuthResult{} // Extract result (first word) parts := strings.Fields(receivedSPF) if len(parts) > 0 { resultStr := strings.ToLower(parts[0]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } result.Details = &receivedSPF @@ -97,14 +98,14 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe return result } -func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateSPFScore(results *model.AuthenticationResults) (score int) { if results.Spf != nil { switch results.Spf.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: return 100 - case api.AuthResultResultNeutral, api.AuthResultResultNone: + case model.AuthResultResultNeutral, model.AuthResultResultNone: return 50 - case api.AuthResultResultSoftfail: + case model.AuthResultResultSoftfail: return 17 default: // fail, temperror, permerror return 0 diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go index 960aef5..210505a 100644 --- a/pkg/analyzer/authentication_spf_test.go +++ b/pkg/analyzer/authentication_spf_test.go @@ -24,38 +24,39 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestParseSPFResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string }{ { name: "SPF pass with domain", part: "spf=pass smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", }, { name: "SPF fail", part: "spf=fail smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", }, { name: "SPF neutral", part: "spf=neutral smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultNeutral, + expectedResult: model.AuthResultResultNeutral, expectedDomain: "example.com", }, { name: "SPF softfail", part: "spf=softfail smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultSoftfail, + expectedResult: model.AuthResultResultSoftfail, expectedDomain: "example.com", }, } @@ -84,7 +85,7 @@ func TestParseLegacySPF(t *testing.T) { tests := []struct { name string receivedSPF string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain *string expectNil bool }{ @@ -97,8 +98,8 @@ func TestParseLegacySPF(t *testing.T) { envelope-from="user@example.com"; helo=smtp.example.com; client-ip=192.0.2.10`, - expectedResult: api.AuthResultResultPass, - expectedDomain: api.PtrTo("example.com"), + expectedResult: model.AuthResultResultPass, + expectedDomain: utils.PtrTo("example.com"), }, { name: "SPF fail with sender", @@ -109,43 +110,43 @@ func TestParseLegacySPF(t *testing.T) { sender="sender@test.com"; helo=smtp.test.com; client-ip=192.0.2.20`, - expectedResult: api.AuthResultResultFail, - expectedDomain: api.PtrTo("test.com"), + expectedResult: model.AuthResultResultFail, + expectedDomain: utils.PtrTo("test.com"), }, { name: "SPF softfail", receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"", - expectedResult: api.AuthResultResultSoftfail, - expectedDomain: api.PtrTo("example.org"), + expectedResult: model.AuthResultResultSoftfail, + expectedDomain: utils.PtrTo("example.org"), }, { name: "SPF neutral", receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"", - expectedResult: api.AuthResultResultNeutral, - expectedDomain: api.PtrTo("domain.net"), + expectedResult: model.AuthResultResultNeutral, + expectedDomain: utils.PtrTo("domain.net"), }, { name: "SPF none", receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"", - expectedResult: api.AuthResultResultNone, - expectedDomain: api.PtrTo("company.io"), + expectedResult: model.AuthResultResultNone, + expectedDomain: utils.PtrTo("company.io"), }, { name: "SPF temperror", receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"", - expectedResult: api.AuthResultResultTemperror, - expectedDomain: api.PtrTo("shop.example"), + expectedResult: model.AuthResultResultTemperror, + expectedDomain: utils.PtrTo("shop.example"), }, { name: "SPF permerror", receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"", - expectedResult: api.AuthResultResultPermerror, - expectedDomain: api.PtrTo("invalid.test"), + expectedResult: model.AuthResultResultPermerror, + expectedDomain: utils.PtrTo("invalid.test"), }, { name: "SPF pass without domain extraction", receivedSPF: "pass (example.com: 192.0.2.50 is authorized)", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: nil, }, { @@ -156,8 +157,8 @@ func TestParseLegacySPF(t *testing.T) { { name: "SPF with unquoted envelope-from", receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", - expectedResult: api.AuthResultResultPass, - expectedDomain: api.PtrTo("mail.example.net"), + expectedResult: model.AuthResultResultPass, + expectedDomain: utils.PtrTo("mail.example.net"), }, } diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 7122f53..44c1abb 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -24,76 +24,77 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestGetAuthenticationScore(t *testing.T) { tests := []struct { name string - results *api.AuthenticationResults + results *model.AuthenticationResults expectedScore int }{ { name: "Perfect authentication (SPF + DKIM + DMARC)", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, - Dmarc: &api.AuthResult{ - Result: api.AuthResultResultPass, + Dmarc: &model.AuthResult{ + Result: model.AuthResultResultPass, }, }, expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25 }, { name: "SPF and DKIM only", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, }, expectedScore: 48, // SPF=25 + DKIM=23 }, { name: "SPF fail, DKIM pass", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultFail, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultFail, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, }, expectedScore: 23, // SPF=0 + DKIM=23 }, { name: "SPF softfail", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultSoftfail, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultSoftfail, }, }, expectedScore: 4, }, { name: "No authentication", - results: &api.AuthenticationResults{}, + results: &model.AuthenticationResults{}, expectedScore: 0, }, { name: "BIMI adds to score", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Bimi: &api.AuthResult{ - Result: api.AuthResultResultPass, + Bimi: &model.AuthResult{ + Result: model.AuthResultResultPass, }, }, expectedScore: 35, // SPF (25) + BIMI (10) @@ -117,30 +118,30 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { tests := []struct { name string header string - expectedSPFResult *api.AuthResultResult + expectedSPFResult *model.AuthResultResult expectedSPFDomain *string expectedDKIMCount int - expectedDKIMResult *api.AuthResultResult - expectedDMARCResult *api.AuthResultResult + expectedDKIMResult *model.AuthResultResult + expectedDMARCResult *model.AuthResultResult expectedDMARCDomain *string - expectedBIMIResult *api.AuthResultResult - expectedARCResult *api.ARCResultResult + expectedBIMIResult *model.AuthResultResult + expectedARCResult *model.ARCResultResult }{ { name: "Complete authentication results", header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCDomain: api.PtrTo("example.com"), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCDomain: utils.PtrTo("example.com"), }, { name: "SPF only", header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("domain.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("domain.com"), expectedDKIMCount: 0, expectedDMARCResult: nil, }, @@ -149,68 +150,68 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1", expectedSPFResult: nil, expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), }, { name: "Multiple DKIM signatures", header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2", expectedSPFResult: nil, expectedDKIMCount: 2, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), expectedDMARCResult: nil, }, { name: "SPF fail with DKIM pass", header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default", - expectedSPFResult: api.PtrTo(api.AuthResultResultFail), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultFail), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), expectedDMARCResult: nil, }, { name: "SPF softfail", header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultSoftfail), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultSoftfail), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 0, expectedDMARCResult: nil, }, { name: "DMARC fail", header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCResult: api.PtrTo(api.AuthResultResultFail), - expectedDMARCDomain: api.PtrTo("example.com"), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: utils.PtrTo(model.AuthResultResultFail), + expectedDMARCDomain: utils.PtrTo("example.com"), }, { name: "BIMI pass", header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 0, - expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), + expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass), }, { name: "ARC pass", header: "mail.example.com; arc=pass", expectedSPFResult: nil, expectedDKIMCount: 0, - expectedARCResult: api.PtrTo(api.ARCResultResultPass), + expectedARCResult: utils.PtrTo(model.ARCResultResultPass), }, { name: "All authentication methods", header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCDomain: api.PtrTo("example.com"), - expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), - expectedARCResult: api.PtrTo(api.ARCResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCDomain: utils.PtrTo("example.com"), + expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass), + expectedARCResult: utils.PtrTo(model.ARCResultResultPass), }, { name: "Empty header (authserv-id only)", @@ -221,8 +222,8 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { { name: "Empty parts with semicolons", header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 0, }, { @@ -230,19 +231,19 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { header: "mail.example.com; dkim=pass d=example.com s=selector1", expectedSPFResult: nil, expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), }, { name: "SPF neutral", header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultNeutral), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 0, }, { name: "SPF none", header: "mail.example.com; spf=none", - expectedSPFResult: api.PtrTo(api.AuthResultResultNone), + expectedSPFResult: utils.PtrTo(model.AuthResultResultNone), expectedDKIMCount: 0, }, } @@ -251,7 +252,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(tt.header, results) // Check SPF @@ -357,13 +358,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) { header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Spf == nil { t.Fatal("Expected SPF result, got nil") } - if results.Spf.Result != api.AuthResultResultPass { + if results.Spf.Result != model.AuthResultResultPass { t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result) } if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" { @@ -373,13 +374,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) { header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Dmarc == nil { t.Fatal("Expected DMARC result, got nil") } - if results.Dmarc.Result != api.AuthResultResultPass { + if results.Dmarc.Result != model.AuthResultResultPass { t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result) } if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" { @@ -389,26 +390,26 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) { header := "mail.example.com; arc=pass; arc=fail" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Arc == nil { t.Fatal("Expected ARC result, got nil") } - if results.Arc.Result != api.ARCResultResultPass { + if results.Arc.Result != model.ARCResultResultPass { t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result) } }) t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) { header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Bimi == nil { t.Fatal("Expected BIMI result, got nil") } - if results.Bimi.Result != api.AuthResultResultPass { + if results.Bimi.Result != model.AuthResultResultPass { t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result) } if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" { @@ -419,7 +420,7 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) { // DKIM is special - multiple signatures should all be collected header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Dkim == nil { @@ -428,10 +429,10 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { if len(*results.Dkim) != 2 { t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim)) } - if (*results.Dkim)[0].Result != api.AuthResultResultPass { + if (*results.Dkim)[0].Result != model.AuthResultResultPass { t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result) } - if (*results.Dkim)[1].Result != api.AuthResultResultFail { + if (*results.Dkim)[1].Result != model.AuthResultResultFail { t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result) } }) diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go index eb0cf98..ec1571c 100644 --- a/pkg/analyzer/authentication_x_aligned_from.go +++ b/pkg/analyzer/authentication_x_aligned_from.go @@ -25,34 +25,35 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results // Example: x-aligned-from=pass (Address match) -func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`x-aligned-from=([\w]+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract details (everything after the result) - result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) return result } -func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) { if results.XAlignedFrom != nil { switch results.XAlignedFrom.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: // pass: positive contribution return 100 - case api.AuthResultResultFail: + case model.AuthResultResultFail: // fail: negative contribution return 0 default: diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go index 0fdd69d..1ea6d1c 100644 --- a/pkg/analyzer/authentication_x_aligned_from_test.go +++ b/pkg/analyzer/authentication_x_aligned_from_test.go @@ -24,44 +24,44 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseXAlignedFromResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDetail string }{ { name: "x-aligned-from pass with details", part: "x-aligned-from=pass (Address match)", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDetail: "pass (Address match)", }, { name: "x-aligned-from fail with reason", part: "x-aligned-from=fail (Address mismatch)", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDetail: "fail (Address mismatch)", }, { name: "x-aligned-from pass minimal", part: "x-aligned-from=pass", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDetail: "pass", }, { name: "x-aligned-from neutral", part: "x-aligned-from=neutral (No alignment check performed)", - expectedResult: api.AuthResultResultNeutral, + expectedResult: model.AuthResultResultNeutral, expectedDetail: "neutral (No alignment check performed)", }, { name: "x-aligned-from none", part: "x-aligned-from=none", - expectedResult: api.AuthResultResultNone, + expectedResult: model.AuthResultResultNone, expectedDetail: "none", }, } @@ -88,34 +88,34 @@ func TestParseXAlignedFromResult(t *testing.T) { func TestCalculateXAlignedFromScore(t *testing.T) { tests := []struct { name string - result *api.AuthResult + result *model.AuthResult expectedScore int }{ { name: "pass result gives positive score", - result: &api.AuthResult{ - Result: api.AuthResultResultPass, + result: &model.AuthResult{ + Result: model.AuthResultResultPass, }, expectedScore: 100, }, { name: "fail result gives zero score", - result: &api.AuthResult{ - Result: api.AuthResultResultFail, + result: &model.AuthResult{ + Result: model.AuthResultResultFail, }, expectedScore: 0, }, { name: "neutral result gives zero score", - result: &api.AuthResult{ - Result: api.AuthResultResultNeutral, + result: &model.AuthResult{ + Result: model.AuthResultResultNeutral, }, expectedScore: 0, }, { name: "none result gives zero score", - result: &api.AuthResult{ - Result: api.AuthResultResultNone, + result: &model.AuthResult{ + Result: model.AuthResultResultNone, }, expectedScore: 0, }, @@ -130,7 +130,7 @@ func TestCalculateXAlignedFromScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &api.AuthenticationResults{ + results := &model.AuthenticationResults{ XAlignedFrom: tt.result, } diff --git a/pkg/analyzer/authentication_x_google_dkim.go b/pkg/analyzer/authentication_x_google_dkim.go index 4bba469..b33279e 100644 --- a/pkg/analyzer/authentication_x_google_dkim.go +++ b/pkg/analyzer/authentication_x_google_dkim.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results // Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6 -func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`x-google-dkim=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -54,15 +55,15 @@ func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthRe result.Selector = &selector } - result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) return result } -func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) { if results.XGoogleDkim != nil { switch results.XGoogleDkim.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: // pass: don't alter the score default: // fail return -100 diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go index f9704c0..4013340 100644 --- a/pkg/analyzer/authentication_x_google_dkim_test.go +++ b/pkg/analyzer/authentication_x_google_dkim_test.go @@ -24,39 +24,39 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseXGoogleDKIMResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string expectedSelector string }{ { name: "x-google-dkim pass with domain", part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "1e100.net", }, { name: "x-google-dkim pass with short form", part: "x-google-dkim=pass d=gmail.com", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "gmail.com", }, { name: "x-google-dkim fail", part: "x-google-dkim=fail header.d=example.com", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", }, { name: "x-google-dkim with minimal info", part: "x-google-dkim=pass", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, }, } diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index d14d157..06f8ddf 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -32,7 +32,8 @@ import ( "time" "unicode" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" "golang.org/x/net/html" ) @@ -728,16 +729,16 @@ func (c *ContentAnalyzer) normalizeText(text string) string { } // GenerateContentAnalysis creates structured content analysis from results -func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis { +func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *model.ContentAnalysis { if results == nil { return nil } - analysis := &api.ContentAnalysis{ - HasHtml: api.PtrTo(results.HTMLContent != ""), - HasPlaintext: api.PtrTo(results.TextContent != ""), - HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe), - UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{}, + analysis := &model.ContentAnalysis{ + HasHtml: utils.PtrTo(results.HTMLContent != ""), + HasPlaintext: utils.PtrTo(results.TextContent != ""), + HasUnsubscribeLink: utils.PtrTo(results.HasUnsubscribe), + UnsubscribeMethods: &[]model.ContentAnalysisUnsubscribeMethods{}, } // Calculate text-to-image ratio (inverse of image-to-text) @@ -750,16 +751,16 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api. } // Build HTML issues - htmlIssues := []api.ContentIssue{} + htmlIssues := []model.ContentIssue{} // 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, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.BrokenHtml, + Severity: model.ContentIssueSeverityHigh, Message: errMsg, - Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"), + Advice: utils.PtrTo("Fix HTML structure errors to improve email rendering across clients"), }) } } @@ -773,53 +774,53 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api. } } if missingAltCount > 0 { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.MissingAlt, - Severity: api.ContentIssueSeverityMedium, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.MissingAlt, + Severity: model.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"), + Advice: utils.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"), }) } } // Add excessive images issue if results.ImageTextRatio > 10.0 { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.ExcessiveImages, - Severity: api.ContentIssueSeverityMedium, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.ExcessiveImages, + Severity: model.ContentIssueSeverityMedium, Message: "Email is excessively image-heavy", - Advice: api.PtrTo("Reduce the number of images relative to text content"), + Advice: utils.PtrTo("Reduce the number of images relative to text content"), }) } // Add suspicious URL issues for _, suspURL := range results.SuspiciousURLs { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.SuspiciousLink, - Severity: api.ContentIssueSeverityHigh, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.SuspiciousLink, + Severity: model.ContentIssueSeverityHigh, Message: "Suspicious URL detected", Location: &suspURL, - Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"), + Advice: utils.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"), }) } // Add harmful HTML tag issues for _, harmfulIssue := range results.HarmfullIssues { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.DangerousHtml, - Severity: api.ContentIssueSeverityCritical, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.DangerousHtml, + Severity: model.ContentIssueSeverityCritical, Message: harmfulIssue, - Advice: api.PtrTo("Remove dangerous HTML tags like