From c96a8b92b8171890cb4272c3e0b15700c5ee69d2 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 11:28:53 +0700 Subject: [PATCH 01/24] Readd missing go deps --- go.mod | 3 ++- go.sum | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 9902a87..038eb22 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/JGLTechnologies/gin-rate-limit v1.5.6 github.com/emersion/go-smtp v0.24.0 + github.com/getkin/kin-openapi v0.133.0 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.3.0 @@ -15,6 +16,7 @@ require ( ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -24,7 +26,6 @@ require ( github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect - github.com/getkin/kin-openapi v0.133.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect diff --git a/go.sum b/go.sum index 5863eca..10c9b72 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI= github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -98,6 +102,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -165,6 +170,7 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= From 71e08324162ae26e225db25752d2c777bbf47075 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 10:27:14 +0700 Subject: [PATCH 02/24] Parse DKIM-Signature headers directly in AnalyzeDNS Remove authResults parameter from AnalyzeDNS, making it independent of the authentication analysis step. Instead, parse DKIM-Signature headers directly to extract domain and selector. Bug: https://github.com/happyDomain/happydeliver/issues/11 --- pkg/analyzer/dns.go | 21 ++-- pkg/analyzer/dns_dkim.go | 32 +++++ pkg/analyzer/dns_dkim_test.go | 214 ++++++++++++++++++++++++++++++++++ pkg/analyzer/report.go | 2 +- 4 files changed, 255 insertions(+), 14 deletions(-) diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 3098934..10babb0 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -54,7 +54,7 @@ func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DN } // AnalyzeDNS performs DNS validation for the email's domain -func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults { +func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.HeaderAnalysis) *api.DNSResults { // Extract domain from From address if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" { return &api.DNSResults{ @@ -104,19 +104,14 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic // SPF validates the MAIL FROM command, which corresponds to Return-Path results.SpfRecords = d.checkSPFRecords(spfDomain) - // Check DKIM records (from authentication results) - // DKIM can be for any domain, but typically the From domain - if authResults != nil && authResults.Dkim != nil { - for _, dkim := range *authResults.Dkim { - if dkim.Domain != nil && dkim.Selector != nil { - dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector) - if dkimRecord != nil { - if results.DkimRecords == nil { - results.DkimRecords = new([]api.DKIMRecord) - } - *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) - } + // Check DKIM records by parsing DKIM-Signature headers directly + for _, sig := range parseDKIMSignatures(email.Header["DKIM-Signature"]) { + dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector) + if dkimRecord != nil { + if results.DkimRecords == nil { + results.DkimRecords = new([]api.DKIMRecord) } + *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) } } diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go index 7ac858d..1a8a199 100644 --- a/pkg/analyzer/dns_dkim.go +++ b/pkg/analyzer/dns_dkim.go @@ -29,6 +29,38 @@ import ( "git.happydns.org/happyDeliver/internal/api" ) +// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header. +type DKIMHeader struct { + Domain string + Selector string +} + +// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values. +func parseDKIMSignatures(signatures []string) []DKIMHeader { + var results []DKIMHeader + for _, sig := range signatures { + var domain, selector string + for _, part := range strings.Split(sig, ";") { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + continue + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "d": + domain = val + case "s": + selector = val + } + } + if domain != "" && selector != "" { + results = append(results, DKIMHeader{Domain: domain, Selector: selector}) + } + } + return results +} + // checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { // DKIM records are at: selector._domainkey.domain diff --git a/pkg/analyzer/dns_dkim_test.go b/pkg/analyzer/dns_dkim_test.go index 8d94d20..45da53c 100644 --- a/pkg/analyzer/dns_dkim_test.go +++ b/pkg/analyzer/dns_dkim_test.go @@ -26,6 +26,220 @@ import ( "time" ) +func TestParseDKIMSignatures(t *testing.T) { + tests := []struct { + name string + signatures []string + expected []DKIMHeader + }{ + { + name: "Empty input", + signatures: nil, + expected: nil, + }, + { + name: "Empty string", + signatures: []string{""}, + expected: nil, + }, + { + name: "Simple Gmail-style", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`, + }, + expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}}, + }, + { + name: "Microsoft 365 style", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`, + }, + expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}}, + }, + { + name: "Tab-folded multiline (Postfix-style)", + signatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==", + }, + expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}}, + }, + { + name: "Space-folded multiline (RFC-style)", + signatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==", + }, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}}, + }, + { + name: "d= and s= on separate continuation lines", + signatures: []string{ + "v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==", + }, + expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}}, + }, + { + name: "No space after semicolons", + signatures: []string{ + `v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`, + }, + expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}}, + }, + { + name: "Multiple spaces after semicolons", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}}, + }, + { + name: "Ed25519 signature (RFC 8463)", + signatures: []string{ + "v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==", + }, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}}, + }, + { + name: "Multiple signatures (ESP double-signing)", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mydomain.com; s=mail; h=from:to:subject; bh=hash1=; b=sig1==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`, + }, + expected: []DKIMHeader{ + {Domain: "mydomain.com", Selector: "mail"}, + {Domain: "sendib.com", Selector: "mail"}, + }, + }, + { + name: "Dual-algorithm signing (Ed25519 + RSA, same domain, different selectors)", + signatures: []string{ + `v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; s=brisbane; h=from:to:subject; bh=hash=; b=edSig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`, + }, + expected: []DKIMHeader{ + {Domain: "football.example.com", Selector: "brisbane"}, + {Domain: "football.example.com", Selector: "test"}, + }, + }, + { + name: "Amazon SES long selectors", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/simple; d=amazonses.com; s=224i4yxa5dv7c2xz3womw6peuabd; h=from:to:subject; bh=sesHash=; b=sesSig==`, + `v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`, + }, + expected: []DKIMHeader{ + {Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"}, + {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"}, + }, + }, + { + name: "Subdomain in d=", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}}, + }, + { + name: "Deeply nested subdomain", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}}, + }, + { + name: "Selector with hyphens (Microsoft 365 custom domain style)", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}}, + }, + { + name: "Selector with dots", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}}, + }, + { + name: "Single-character selector", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}}, + }, + { + name: "Postmark-style timestamp selector, s= before d=", + signatures: []string{ + `v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`, + }, + expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}}, + }, + { + name: "d= and s= at the very end", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`, + }, + expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}}, + }, + { + name: "Full tag set", + signatures: []string{ + `v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}}, + }, + { + name: "Missing d= tag", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; s=selector1; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Missing s= tag", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Missing both d= and s= tags", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Mix of valid and invalid signatures", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=good.com; s=sel1; h=from:to; bh=hash=; b=sig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; s=orphan; h=from:to; bh=hash=; b=sig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{ + {Domain: "good.com", Selector: "sel1"}, + {Domain: "also-good.com", Selector: "sel2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseDKIMSignatures(tt.signatures) + if len(result) != len(tt.expected) { + t.Fatalf("parseDKIMSignatures() returned %d results, want %d\n got: %+v\n want: %+v", len(result), len(tt.expected), result, tt.expected) + } + for i := range tt.expected { + if result[i].Domain != tt.expected[i].Domain { + t.Errorf("result[%d].Domain = %q, want %q", i, result[i].Domain, tt.expected[i].Domain) + } + if result[i].Selector != tt.expected[i].Selector { + t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector) + } + } + }) + } +} + func TestValidateDKIM(t *testing.T) { tests := []struct { name string diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index bd12960..354f911 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -83,7 +83,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { // Run all analyzers results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) - results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers) + results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) results.DNSWL = r.dnswlChecker.CheckEmail(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) From 76ee50a1001689c69483b44d6f21f527149f977d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 10:58:34 +0700 Subject: [PATCH 03/24] Make receiver hostname configurable via --receiver-hostname flag Remove the package-level global hostname from parser.go. Adds a log warning when the last Received hop doesn't match the expected receiver hostname. Bug: https://github.com/happyDomain/happydeliver/issues/11 --- internal/config/cli.go | 1 + internal/config/config.go | 7 +++++++ internal/receiver/receiver.go | 11 +++++++++++ pkg/analyzer/analyzer.go | 1 + pkg/analyzer/authentication.go | 10 ++++++---- pkg/analyzer/authentication_arc_test.go | 4 ++-- pkg/analyzer/authentication_bimi_test.go | 2 +- pkg/analyzer/authentication_dkim_test.go | 2 +- pkg/analyzer/authentication_dmarc_test.go | 2 +- pkg/analyzer/authentication_iprev_test.go | 4 ++-- pkg/analyzer/authentication_spf_test.go | 4 ++-- pkg/analyzer/authentication_test.go | 6 +++--- .../authentication_x_aligned_from_test.go | 4 ++-- pkg/analyzer/authentication_x_google_dkim_test.go | 2 +- pkg/analyzer/parser.go | 15 ++++----------- pkg/analyzer/parser_test.go | 5 +---- pkg/analyzer/report.go | 3 ++- pkg/analyzer/report_test.go | 10 +++++----- 18 files changed, 53 insertions(+), 40 deletions(-) diff --git a/internal/config/cli.go b/internal/config/cli.go index 3accc99..3a426bf 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -34,6 +34,7 @@ func declareFlags(o *Config) { flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address") + flag.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())") flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query") flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") diff --git a/internal/config/config.go b/internal/config/config.go index 468a2aa..37e4314 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,6 +34,11 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +func getHostname() string { + h, _ := os.Hostname() + return h +} + // Config represents the application configuration type Config struct { DevProxy string @@ -58,6 +63,7 @@ type EmailConfig struct { Domain string TestAddressPrefix string LMTPAddr string + ReceiverHostname string } // AnalysisConfig contains timeout and behavior settings for email analysis @@ -84,6 +90,7 @@ func DefaultConfig() *Config { Domain: "happydeliver.local", TestAddressPrefix: "test-", LMTPAddr: "127.0.0.1:2525", + ReceiverHostname: getHostname(), }, Analysis: AnalysisConfig{ DNSTimeout: 5 * time.Second, diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index 062a091..f06f535 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -98,6 +98,17 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score) + // Warn if the last Received hop doesn't match the expected receiver hostname + if r.config.Email.ReceiverHostname != "" && + result.Report.HeaderAnalysis != nil && + result.Report.HeaderAnalysis.ReceivedChain != nil && + len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 { + lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0] + if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname { + log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname) + } + } + // Marshal report to JSON reportJSON, err := json.Marshal(result.Report) if err != nil { diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index a16829b..3793218 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -41,6 +41,7 @@ type EmailAnalyzer struct { // NewEmailAnalyzer creates a new email analyzer with the given configuration func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { generator := NewReportGenerator( + cfg.Email.ReceiverHostname, cfg.Analysis.DNSTimeout, cfg.Analysis.HTTPTimeout, cfg.Analysis.RBLs, diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 07f7794..2051a56 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -28,11 +28,13 @@ import ( ) // AuthenticationAnalyzer analyzes email authentication results -type AuthenticationAnalyzer struct{} +type AuthenticationAnalyzer struct { + receiverHostname string +} // NewAuthenticationAnalyzer creates a new authentication analyzer -func NewAuthenticationAnalyzer() *AuthenticationAnalyzer { - return &AuthenticationAnalyzer{} +func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer { + return &AuthenticationAnalyzer{receiverHostname: receiverHostname} } // AnalyzeAuthentication extracts and analyzes authentication results from email headers @@ -40,7 +42,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api results := &api.AuthenticationResults{} // Parse Authentication-Results headers - authHeaders := email.GetAuthenticationResults() + authHeaders := email.GetAuthenticationResults(a.receiverHostname) for _, header := range authHeaders { a.parseAuthenticationResultsHeader(header, results) } diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go index 9269d70..7f2f99e 100644 --- a/pkg/analyzer/authentication_arc_test.go +++ b/pkg/analyzer/authentication_arc_test.go @@ -50,7 +50,7 @@ func TestParseARCResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -136,7 +136,7 @@ func TestValidateARCChain(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go index b1b5468..7cb9c85 100644 --- a/pkg/analyzer/authentication_bimi_test.go +++ b/pkg/analyzer/authentication_bimi_test.go @@ -64,7 +64,7 @@ func TestParseBIMIResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go index 2aab530..3218639 100644 --- a/pkg/analyzer/authentication_dkim_test.go +++ b/pkg/analyzer/authentication_dkim_test.go @@ -58,7 +58,7 @@ func TestParseDKIMResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go index d7fda84..3b8fb08 100644 --- a/pkg/analyzer/authentication_dmarc_test.go +++ b/pkg/analyzer/authentication_dmarc_test.go @@ -48,7 +48,7 @@ func TestParseDMARCResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go index d0529b5..5b46995 100644 --- a/pkg/analyzer/authentication_iprev_test.go +++ b/pkg/analyzer/authentication_iprev_test.go @@ -93,7 +93,7 @@ func TestParseIPRevResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -181,7 +181,7 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go index 7a84c49..960aef5 100644 --- a/pkg/analyzer/authentication_spf_test.go +++ b/pkg/analyzer/authentication_spf_test.go @@ -60,7 +60,7 @@ func TestParseSPFResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -161,7 +161,7 @@ func TestParseLegacySPF(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 27901b5..7122f53 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -100,7 +100,7 @@ func TestGetAuthenticationScore(t *testing.T) { }, } - scorer := NewAuthenticationAnalyzer() + scorer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -247,7 +247,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -353,7 +353,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { // This test verifies that only the first occurrence of each auth method is parsed - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") 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" diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go index 220ac39..0fdd69d 100644 --- a/pkg/analyzer/authentication_x_aligned_from_test.go +++ b/pkg/analyzer/authentication_x_aligned_from_test.go @@ -66,7 +66,7 @@ func TestParseXAlignedFromResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -126,7 +126,7 @@ func TestCalculateXAlignedFromScore(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go index be29a08..f9704c0 100644 --- a/pkg/analyzer/authentication_x_google_dkim_test.go +++ b/pkg/analyzer/authentication_x_google_dkim_test.go @@ -60,7 +60,7 @@ func TestParseXGoogleDKIMResult(t *testing.T) { }, } - analyzer := NewAuthenticationAnalyzer() + analyzer := NewAuthenticationAnalyzer("") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/analyzer/parser.go b/pkg/analyzer/parser.go index 5b30e07..00de151 100644 --- a/pkg/analyzer/parser.go +++ b/pkg/analyzer/parser.go @@ -28,16 +28,9 @@ import ( "mime/multipart" "net/mail" "net/textproto" - "os" "strings" ) -var hostname = "" - -func init() { - hostname, _ = os.Hostname() -} - // EmailMessage represents a parsed email message type EmailMessage struct { Header mail.Header @@ -218,18 +211,18 @@ func buildRawHeaders(header mail.Header) string { } // GetAuthenticationResults extracts Authentication-Results headers -// If hostname is provided, only returns headers that begin with that hostname -func (e *EmailMessage) GetAuthenticationResults() []string { +// If receiverHostname is provided, only returns headers that begin with that hostname +func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string { allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] // If no hostname specified, return all results - if hostname == "" { + if receiverHostname == "" { return allResults } // Filter results that begin with the specified hostname var filtered []string - prefix := hostname + ";" + prefix := receiverHostname + ";" for _, result := range allResults { // Trim whitespace and check if it starts with hostname; trimmed := strings.TrimSpace(result) diff --git a/pkg/analyzer/parser_test.go b/pkg/analyzer/parser_test.go index eb1fc6a..196e8e2 100644 --- a/pkg/analyzer/parser_test.go +++ b/pkg/analyzer/parser_test.go @@ -106,9 +106,6 @@ Content-Type: text/html; charset=utf-8 } func TestGetAuthenticationResults(t *testing.T) { - // Force hostname - hostname = "example.com" - rawEmail := `From: sender@example.com To: recipient@example.com Subject: Test Email @@ -123,7 +120,7 @@ Body content. t.Fatalf("Failed to parse email: %v", err) } - authResults := email.GetAuthenticationResults() + authResults := email.GetAuthenticationResults("example.com") if len(authResults) != 2 { t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults)) } diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 354f911..78d70f7 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -43,6 +43,7 @@ type ReportGenerator struct { // NewReportGenerator creates a new report generator func NewReportGenerator( + receiverHostname string, dnsTimeout time.Duration, httpTimeout time.Duration, rbls []string, @@ -50,7 +51,7 @@ func NewReportGenerator( checkAllIPs bool, ) *ReportGenerator { return &ReportGenerator{ - authAnalyzer: NewAuthenticationAnalyzer(), + authAnalyzer: NewAuthenticationAnalyzer(receiverHostname), spamAnalyzer: NewSpamAssassinAnalyzer(), rspamdAnalyzer: NewRspamdAnalyzer(), dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index 82e923e..dd76213 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, DefaultDNSWLs, 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, DefaultDNSWLs, 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, DefaultDNSWLs, 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, DefaultDNSWLs, 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, DefaultDNSWLs, false) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) tests := []struct { name string From 26025c96a223ae30dbe4327c1724d637f11167e2 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 11:10:47 +0700 Subject: [PATCH 04/24] Document --receiver-hostname flag and HAPPYDELIVER_RECEIVER_HOSTNAME env var Explain how happyDeliver filters Authentication-Results headers by hostname, how to find the correct authserv-id value, and when to override it (especially when bypassing the embedded Postfix). Bug: https://github.com/happyDomain/happydeliver/issues/1 Bug: https://github.com/happyDomain/happydeliver/issues/11 --- README.md | 19 ++++++++++++++++++- docker/README.md | 28 ++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3c213cd..4c4013b 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,24 @@ The server will start on `http://localhost:8080` by default. It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ... happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations. -Choose one of the following way to integrate happyDeliver in your existing setup: +#### Receiver Hostname + +happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`). + +If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly: + +```bash +./happyDeliver server -receiver-hostname mail.example.com +``` + +Or via environment variable: +```bash +HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server +``` + +**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`. + +If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. #### Postfix LMTP Transport diff --git a/docker/README.md b/docker/README.md index 3769365..2199eeb 100644 --- a/docker/README.md +++ b/docker/README.md @@ -110,14 +110,38 @@ Default configuration for the Docker environment: The container accepts these environment variables: - `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local) +- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below) +- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP -Note that the hostname of the container is used to filter the authentication tests results. +### Receiver Hostname -Example: +happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`). + +In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically. + +**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname: + +```bash +docker run -d \ + -e HAPPYDELIVER_DOMAIN=example.com \ + -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \ + ... +``` + +To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`. + +If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname. + +Example (all-in-one, no override needed): ```bash docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ... ``` +Example (external MTA integration): +```bash +docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ... +``` + ## Volumes **Required volumes:** From 2fcee1b8857c8c2a991d22fd30bfe0a41f271c88 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 11:29:36 +0700 Subject: [PATCH 05/24] Return nil from spam analyzers when primary headers are missing Bug: https://github.com/happyDomain/happydeliver/issues/11 --- pkg/analyzer/rspamd.go | 7 +++++++ pkg/analyzer/spamassassin.go | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go index f3f548b..3fed81d 100644 --- a/pkg/analyzer/rspamd.go +++ b/pkg/analyzer/rspamd.go @@ -51,6 +51,13 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult { return nil } + // Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report + _, hasSpamdResult := headers["X-Spamd-Result"] + _, hasRspamdScore := headers["X-Rspamd-Score"] + if !hasSpamdResult && !hasRspamdScore { + return nil + } + result := &api.RspamdResult{ Symbols: make(map[string]api.RspamdSymbol), } diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index 7964af2..d6ae961 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -45,6 +45,14 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa return nil } + // Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report + _, hasStatus := headers["X-Spam-Status"] + _, hasScore := headers["X-Spam-Score"] + _, hasFlag := headers["X-Spam-Flag"] + if !hasStatus && !hasScore && !hasFlag { + return nil + } + result := &api.SpamAssassinResult{ TestDetails: make(map[string]api.SpamTestDetail), } From 35fc997390c22e92408108efb15435b5d7b229ea Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 12:11:18 +0700 Subject: [PATCH 06/24] Add warning banner when all authentication results are missing Explains the two most common causes: the mail server not being configured to verify email authentication, or a receiver hostname mismatch with --receiver-hostname. Bug: https://github.com/happyDomain/happydeliver/issues/11 --- .../lib/components/AuthenticationCard.svelte | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 93531e7..46a4d2d 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -13,6 +13,12 @@ let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props(); + let allRequiredMissing = $derived( + !authentication.spf && + (!authentication.dkim || authentication.dkim.length === 0) && + !authentication.dmarc, + ); + function getAuthResultClass(result: string, noneIsFail: boolean): string { switch (result) { case "pass": @@ -97,6 +103,28 @@ + {#if allRequiredMissing} +
+
+ + No authentication results found. +

+ This usually means either: +

+
    +
  • + The receiving mail server is not configured to verify email authentication + (no Authentication-Results header was found in the message). +
  • +
  • + The Authentication-Results header exists but the receiver + hostname does not match the configured + --receiver-hostname value. +
  • +
+
+
+ {/if}
{#if authentication.iprev} From 3c192f17fd6b39c22c8dcd582ca6a41c3862d65f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 25 Mar 2026 12:28:18 +0700 Subject: [PATCH 07/24] Improve DKIM summary to distinguish missing records from invalid signatures Use DNS records instead of authentication results to determine DKIM presence, enabling a three-state display: passed (green), published but invalid signature (yellow+red), or no DKIM at all (red). --- web/src/lib/components/SummaryCard.svelte | 28 ++++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte index 5d93513..518e996 100644 --- a/web/src/lib/components/SummaryCard.svelte +++ b/web/src/lib/components/SummaryCard.svelte @@ -25,16 +25,32 @@ // Email sender information const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender"; - const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0; - const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass"); + const hasDkim = + report.dns_results?.dkim_records && report.dns_results?.dkim_records?.length > 0; + const dkimPassed = + report.authentication?.dkim && + report.authentication?.dkim.length > 0 && + report.authentication?.dkim?.some((d) => d.result === "pass"); segments.push({ text: "Received a " }); segments.push({ - text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed", - highlight: { color: dkimPassed ? "good" : "danger", bold: true }, - link: "#authentication-dkim", + text: hasDkim ? "DKIM-signed" : "non-DKIM-signed", + highlight: { + color: hasDkim ? (dkimPassed ? "good" : "warning") : "danger", + bold: true, + }, + link: hasDkim && dkimPassed ? "#authentication-dkim" : "#dns-details", }); - segments.push({ text: " email from " }); + segments.push({ text: " email" }); + if (hasDkim && !dkimPassed) { + segments.push({ text: " with " }); + segments.push({ + text: "an invalid signature", + highlight: { color: "danger", bold: true }, + link: "#authentication-dkim", + }); + } + segments.push({ text: " from " }); segments.push({ text: mailFrom, highlight: { emphasis: true }, From 5c104f3c99fd5b287f4fea170518b7db4961de6a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 26 Mar 2026 08:58:03 +0700 Subject: [PATCH 08/24] Merge RspamdSymbol into SpamTestDetail in OpenAPI spec Add params field to SpamTestDetail, update RspamdResult.symbols to reference SpamTestDetail instead of the now-removed RspamdSymbol schema, and update Go code accordingly. --- api/openapi.yaml | 25 +++++-------------------- pkg/analyzer/rspamd.go | 4 ++-- pkg/analyzer/rspamd_test.go | 2 +- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index e989261..225e26c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -926,6 +926,10 @@ components: 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 @@ -975,7 +979,7 @@ components: symbols: type: object additionalProperties: - $ref: '#/components/schemas/RspamdSymbol' + $ref: '#/components/schemas/SpamTestDetail' description: Map of triggered rspamd symbols to their details example: BAYES_HAM: @@ -986,25 +990,6 @@ components: type: string description: Full rspamd report (raw X-Spamd-Result header) - RspamdSymbol: - type: object - required: - - name - - score - properties: - name: - type: string - description: Symbol name - example: "BAYES_HAM" - score: - type: number - format: float - description: Score contribution of this symbol - example: -1.9 - params: - type: string - description: Symbol parameters or options - example: "0.02" DNSResults: type: object diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go index 3fed81d..f37467b 100644 --- a/pkg/analyzer/rspamd.go +++ b/pkg/analyzer/rspamd.go @@ -59,7 +59,7 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult { } result := &api.RspamdResult{ - Symbols: make(map[string]api.RspamdSymbol), + Symbols: make(map[string]api.SpamTestDetail), } // Parse X-Spamd-Result header (primary source for score, threshold, and symbols) @@ -129,7 +129,7 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul if len(matches) > 2 { name := matches[1] score, _ := strconv.ParseFloat(matches[2], 64) - sym := api.RspamdSymbol{ + sym := api.SpamTestDetail{ Name: name, Score: float32(score), } diff --git a/pkg/analyzer/rspamd_test.go b/pkg/analyzer/rspamd_test.go index de98fe8..df37744 100644 --- a/pkg/analyzer/rspamd_test.go +++ b/pkg/analyzer/rspamd_test.go @@ -131,7 +131,7 @@ func TestParseSpamdResult(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := &api.RspamdResult{ - Symbols: make(map[string]api.RspamdSymbol), + Symbols: make(map[string]api.SpamTestDetail), } analyzer.parseSpamdResult(tt.header, result) From 7d3009d7d0cb0ade47371ac39f60448a6b04aa5e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 26 Mar 2026 09:25:42 +0700 Subject: [PATCH 09/24] Add rspamd symbol descriptions from embedded/API lookup Embed rspamd-symbols.json in the binary to provide human-readable descriptions for rspamd symbols in reports. Optionally fetch fresh symbols from a configurable rspamd API URL (--rspamd-api-url flag), falling back to the embedded list on error. Update the frontend to display descriptions alongside symbol names and scores. --- Dockerfile | 3 +- internal/config/cli.go | 1 + internal/config/config.go | 3 +- pkg/analyzer/analyzer.go | 1 + pkg/analyzer/report.go | 3 +- pkg/analyzer/report_test.go | 10 +- pkg/analyzer/rspamd-symbols-README.md | 21 + pkg/analyzer/rspamd-symbols.json | 6646 ++++++++++++++++++++++ pkg/analyzer/rspamd.go | 20 +- pkg/analyzer/rspamd_symbols.go | 105 + pkg/analyzer/rspamd_test.go | 10 +- web/src/lib/components/RspamdCard.svelte | 13 +- 12 files changed, 6816 insertions(+), 20 deletions(-) create mode 100644 pkg/analyzer/rspamd-symbols-README.md create mode 100644 pkg/analyzer/rspamd-symbols.json create mode 100644 pkg/analyzer/rspamd_symbols.go diff --git a/Dockerfile b/Dockerfile index 9626813..4568784 100644 --- a/Dockerfile +++ b/Dockerfile @@ -175,7 +175,8 @@ ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \ HAPPYDELIVER_DOMAIN=happydeliver.local \ HAPPYDELIVER_ADDRESS_PREFIX=test- \ HAPPYDELIVER_DNS_TIMEOUT=5s \ - HAPPYDELIVER_HTTP_TIMEOUT=10s + HAPPYDELIVER_HTTP_TIMEOUT=10s \ + HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334 # Volume for persistent data VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"] diff --git a/internal/config/cli.go b/internal/config/cli.go index 3a426bf..77108ca 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -39,6 +39,7 @@ func declareFlags(o *Config) { flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)") + flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)") flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever") flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") diff --git a/internal/config/config.go b/internal/config/config.go index 37e4314..9d803d0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -72,7 +72,8 @@ type AnalysisConfig struct { HTTPTimeout time.Duration RBLs []string DNSWLs []string - CheckAllIPs bool // Check all IPs found in headers, not just the first one + CheckAllIPs bool // Check all IPs found in headers, not just the first one + RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list) } // DefaultConfig returns a configuration with sensible defaults diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 3793218..54d9e42 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -47,6 +47,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { cfg.Analysis.RBLs, cfg.Analysis.DNSWLs, cfg.Analysis.CheckAllIPs, + cfg.Analysis.RspamdAPIURL, ) return &EmailAnalyzer{ diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 78d70f7..6dcf588 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -49,11 +49,12 @@ func NewReportGenerator( rbls []string, dnswls []string, checkAllIPs bool, + rspamdAPIURL string, ) *ReportGenerator { return &ReportGenerator{ authAnalyzer: NewAuthenticationAnalyzer(receiverHostname), spamAnalyzer: NewSpamAssassinAnalyzer(), - rspamdAnalyzer: NewRspamdAnalyzer(), + rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)), dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs), dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs), diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index dd76213..5914737 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, DefaultDNSWLs, 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, DefaultDNSWLs, 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, DefaultDNSWLs, 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, DefaultDNSWLs, 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, DefaultDNSWLs, false) + gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "") tests := []struct { name string diff --git a/pkg/analyzer/rspamd-symbols-README.md b/pkg/analyzer/rspamd-symbols-README.md new file mode 100644 index 0000000..882eab2 --- /dev/null +++ b/pkg/analyzer/rspamd-symbols-README.md @@ -0,0 +1,21 @@ +# rspamd-symbols.json + +This file contains rspamd symbol descriptions, embedded into the binary at compile time as a fallback when no rspamd API URL is configured. + +## How to update + +Fetch the latest symbols from a running rspamd instance: + +```sh +curl http://127.0.0.1:11334/symbols > rspamd-symbols.json +``` + +Or with docker: + +```sh +docker run --rm --name rspamd --pull always rspamd/rspamd +docker exec -u 0 rspamd apt install -y curl +docker exec rspamd curl http://127.0.0.1:11334/symbols > rspamd-symbols.json +``` + +Then rebuild the project. diff --git a/pkg/analyzer/rspamd-symbols.json b/pkg/analyzer/rspamd-symbols.json new file mode 100644 index 0000000..5538985 --- /dev/null +++ b/pkg/analyzer/rspamd-symbols.json @@ -0,0 +1,6646 @@ +[ + { + "group": "arc", + "rules": [ + { + "symbol": "ARC_ALLOW", + "weight": -1.0, + "description": "ARC checks success", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_REJECT", + "weight": 1.0, + "description": "ARC checks failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_NA", + "weight": 0.0, + "description": "ARC signature absent", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_INVALID", + "weight": 0.500000, + "description": "ARC structure invalid", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_DNSFAIL", + "weight": 0.0, + "description": "ARC DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "rbl", + "rules": [ + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA_BOT", + "weight": 1.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_0", + "weight": 4.0, + "description": "SenderScore Reputation: Very Bad (0-9).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_2", + "weight": 3.0, + "description": "SenderScore Reputation: Bad (20-29).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_RED", + "weight": 0.500000, + "description": "A domain in the message is listed in URIBL.com red", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth" + }, + { + "symbol": "RECEIVED_SPAMHAUS", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_CSS", + "weight": 1.0, + "description": "Received address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from SenderScore RPBL" + }, + { + "symbol": "RBL_VIRUSFREE_BOTNET", + "weight": 2.0, + "description": "From address is listed in virusfree.cz BL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_HI", + "weight": -3.500000, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_VIRUSFREE_UNKNOWN", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_BAD", + "weight": 1.0, + "description": "From address is listed in Mailspike RBL - bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_SBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_BLOCKLISTDE", + "weight": 3.0, + "description": "Received address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CRACKED_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as cracked", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_CRACKED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_4", + "weight": 2.0, + "description": "SenderScore Reputation: Bad (40-49).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA_BOT", + "weight": 3.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_8", + "weight": 0.0, + "description": "SenderScore Reputation: Neutral (80-89).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_MED", + "weight": -0.200000, + "description": "Sender listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_NONE", + "weight": 0.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL", + "weight": 7.500000, + "description": "MSBL emailbl (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_XBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+botnet" + }, + { + "symbol": "SURBL_HASHBL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth+botnet" + }, + { + "symbol": "RECEIVED_SPAMHAUS_SBL", + "weight": 3.0, + "description": "Received address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_POSSIBLE", + "weight": 0.0, + "description": "From address is listed in Mailspike RWL - possibly legit", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_HI", + "weight": -0.500000, + "description": "Sender listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_PBL", + "weight": 2.0, + "description": "From address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_LOW", + "weight": -1.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_7", + "weight": 0.500000, + "description": "SenderScore Reputation: Bad (70-79).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_MALWARE", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_MALWARE", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_BLOCKLISTDE", + "weight": 4.0, + "description": "From address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_SPAM", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as abused", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_MALWARE", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_PHISH", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_DROP", + "weight": 6.0, + "description": "Received address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score+noauth" + }, + { + "symbol": "DBL_ABUSE_REDIR", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as a clicktracker", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_EMAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_SUS_ATT_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - sender_score+suspect_attachments+noauth" + }, + { + "symbol": "RECEIVED_SPAMHAUS_XBL", + "weight": 1.0, + "description": "Received address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_GOOD", + "weight": -0.100000, + "description": "From address is listed in Mailspike RWL - good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine" + }, + { + "symbol": "RBL_MAILSPIKE_VERYBAD", + "weight": 1.500000, + "description": "From address is listed in Mailspike RBL - very bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_IPV6", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL (IPv6)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MW_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_NA", + "weight": 0.0, + "description": "From address is listed in SenderScore RPBL - noauth" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_9", + "weight": -1.0, + "description": "SenderScore Reputation: Good (90-100).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_GREY", + "weight": 2.500000, + "description": "A domain in the message is listed in URIBL.com grey", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_LOW", + "weight": -0.100000, + "description": "Sender listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_PHISH", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_NONE", + "weight": 0.0, + "description": "Sender listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST_NA", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine+noauth" + }, + { + "symbol": "MSBL_EBL_GREY", + "weight": 0.500000, + "description": "MSBL emailbl grey list (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_1", + "weight": 3.500000, + "description": "SenderScore Reputation: Bad (10-19).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_BOT", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - botnet" + }, + { + "symbol": "SEM_URIBL_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_NEUTRAL", + "weight": 0.0, + "description": "Neutral result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_ABUSE", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_6", + "weight": 1.0, + "description": "SenderScore Reputation: Bad (60-69).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL", + "weight": 3.500000, + "description": "A domain in the message is listed in Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_PBL", + "weight": 0.0, + "description": "Received address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DM_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as belonging to a disposable email service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_5", + "weight": 1.500000, + "description": "SenderScore Reputation: Bad (50-59).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_WORST", + "weight": 2.0, + "description": "From address is listed in Mailspike RBL - worst possible reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_BOTNET", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth" + }, + { + "symbol": "DWL_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_CSS", + "weight": 2.0, + "description": "From address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine" + }, + { + "symbol": "DWL_DNSWL_MED", + "weight": -2.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_DROP", + "weight": 7.0, + "description": "From address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN", + "weight": 0.0, + "description": "Unrecognized result from SenderScore Reputation list.", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus DBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILSPIKE", + "weight": 0.0, + "description": "Unrecognised result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score" + }, + { + "symbol": "RBL_SPAMHAUS", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_VERYGOOD", + "weight": -0.200000, + "description": "From address is listed in Mailspike RWL - very good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_3", + "weight": 2.500000, + "description": "SenderScore Reputation: Bad (30-39).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI", + "weight": 0.0, + "description": "Unrecognised result from URIBL.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15", + "weight": 3.0, + "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_EXCELLENT", + "weight": -0.400000, + "description": "From address is listed in Mailspike RWL - excellent reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL", + "weight": 2.500000, + "description": "Rspamd emailbl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLACK", + "weight": 7.500000, + "description": "A domain in the message is listed in URIBL.com black", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_URIBL", + "weight": 4.500000, + "description": "Rspamd uribl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_MULTI", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_NA_BOT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - noauth+botnet" + }, + { + "symbol": "DBL_PROHIBIT", + "weight": 0.0, + "description": "DBL uribl IP queries prohibited!", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BOTNET", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PHISH", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "dnswl", + "rules": [ + { + "symbol": "RCVD_IN_DNSWL_MED", + "weight": -0.200000, + "description": "Sender listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_LOW", + "weight": -0.100000, + "description": "Sender listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_NONE", + "weight": 0.0, + "description": "Sender listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL", + "weight": 0.0, + "description": "Unrecognised result from https://www.dnswl.org", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_HI", + "weight": -0.500000, + "description": "Sender listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_LOW", + "weight": -1.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, low trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_NONE", + "weight": 0.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, no trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_HI", + "weight": -3.500000, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, high trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_MED", + "weight": -2.0, + "description": "Message has a valid dkim signature originated from domain listed at https://www.dnswl.org, medium trust", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "dmarc", + "rules": [ + { + "symbol": "DMARC_POLICY_ALLOW", + "weight": -0.500000, + "description": "DMARC permit policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_REJECT", + "weight": 2.0, + "description": "DMARC reject policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES", + "weight": -0.500000, + "description": "DMARC permit policy with DKIM/SPF failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_SOFTFAIL", + "weight": 0.100000, + "description": "DMARC failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_NA", + "weight": 0.0, + "description": "No DMARC record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_QUARANTINE", + "weight": 1.500000, + "description": "DMARC quarantine policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_DNSFAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_BAD_POLICY", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "statistics", + "rules": [ + { + "symbol": "BAYES_SPAM", + "weight": 5.100000, + "description": "Message probably spam, probability: ", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BAYES_HAM", + "weight": -3.0, + "description": "Message probably ham, probability: ", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "dkim", + "rules": [ + { + "symbol": "R_DKIM_ALLOW", + "weight": -0.200000, + "description": "DKIM verification succeed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DKIM", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_REJECT", + "weight": 1.0, + "description": "DKIM verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF_DKIM", + "weight": -3.0, + "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_TEMPFAIL", + "weight": 0.0, + "description": "DKIM verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_CHECK", + "weight": 0.0, + "description": "DKIM check callback", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DKIM", + "weight": 2.0, + "description": "Mail comes from the whitelisted domain and has non-valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_PERMFAIL", + "weight": 0.0, + "description": "DKIM verification hard-failed (invalid)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF_DKIM", + "weight": 3.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_NA", + "weight": 0.0, + "description": "Missing DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_TRACE", + "weight": 0.0, + "description": "DKIM trace symbol", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "sem", + "rules": [ + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15", + "weight": 3.0, + "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL", + "weight": 3.500000, + "description": "A domain in the message is listed in Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_IPV6", + "weight": 1.0, + "description": "From address is listed in Spameatingmonkey RBL (IPv6)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "neural", + "rules": [] + }, + { + "group": "policies", + "rules": [ + { + "symbol": "R_SPF_NA", + "weight": 0.0, + "description": "Missing SPF record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_TEMPFAIL", + "weight": 0.0, + "description": "DKIM verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_SOFTFAIL", + "weight": 0.100000, + "description": "DMARC failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_ALLOW", + "weight": -1.0, + "description": "ARC checks success", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_ALLOW", + "weight": -0.200000, + "description": "SPF verification allows sending", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_NA", + "weight": 0.0, + "description": "Missing DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_BAD_POLICY", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_NA", + "weight": 0.0, + "description": "No DMARC record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES", + "weight": -0.500000, + "description": "DMARC permit policy with DKIM/SPF failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PLUSALL", + "weight": 4.0, + "description": "SPF record allows to send from any IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_SOFTFAIL", + "weight": 0.0, + "description": "SPF verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_INVALID", + "weight": 0.500000, + "description": "ARC structure invalid", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_DNSFAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_PERMFAIL", + "weight": 0.0, + "description": "DKIM verification hard-failed (invalid)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_TRACE", + "weight": 0.0, + "description": "DKIM trace symbol", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW", + "weight": -0.500000, + "description": "DMARC permit policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_CHECK", + "weight": 0.0, + "description": "DKIM check callback", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_DNSFAIL", + "weight": 0.0, + "description": "ARC DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_REJECT", + "weight": 1.0, + "description": "ARC checks failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PERMFAIL", + "weight": 0.0, + "description": "SPF record is malformed or persistent DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_NA", + "weight": 0.0, + "description": "ARC signature absent", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_NEUTRAL", + "weight": 0.0, + "description": "SPF policy is neutral", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_QUARANTINE", + "weight": 1.500000, + "description": "DMARC quarantine policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_FAIL", + "weight": 1.0, + "description": "SPF verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_DNSFAIL", + "weight": 0.0, + "description": "SPF DNS failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_REJECT", + "weight": 2.0, + "description": "DMARC reject policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_ALLOW", + "weight": -0.200000, + "description": "DKIM verification succeed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_DKIM_REJECT", + "weight": 1.0, + "description": "DKIM verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ARC_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "surbl", + "rules": [ + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_BOTNET", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PROHIBIT", + "weight": 0.0, + "description": "DBL uribl IP queries prohibited!", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAMHAUS_ZEN_URIBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN URIBL" + }, + { + "symbol": "MSBL_EBL", + "weight": 7.500000, + "description": "MSBL emailbl (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BOTNET", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL", + "weight": 2.500000, + "description": "Rspamd emailbl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as a clicktracker", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL", + "weight": 3.500000, + "description": "A domain in the message is listed in Spameatingmonkey URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_URIBL", + "weight": 4.500000, + "description": "Rspamd uribl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN", + "weight": 0.0, + "description": "Unrecognised result from Spameatingmonkey Fresh15 URIBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_SBL", + "weight": 6.500000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus SBL" + }, + { + "symbol": "URIBL_BLACK", + "weight": 7.500000, + "description": "A domain in the message is listed in URIBL.com black", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as abused", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_REDIR", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_PBL", + "weight": 0.010000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus PBL" + }, + { + "symbol": "DBL_ABUSE_PHISH", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL_GREY", + "weight": 0.500000, + "description": "MSBL emailbl grey list (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_SPAM", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CRACKED_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as cracked", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_GREY", + "weight": 2.500000, + "description": "A domain in the message is listed in URIBL.com grey", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_RED", + "weight": 0.500000, + "description": "A domain in the message is listed in URIBL.com red", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_DROP", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus DROP" + }, + { + "symbol": "DBL_PHISH", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI", + "weight": 0.0, + "description": "Unrecognised result from URIBL.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_MALWARE", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus DBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_MALWARE", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MW_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_XBL", + "weight": 3.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus XBL" + }, + { + "symbol": "SEM_URIBL_FRESH15", + "weight": 3.0, + "description": "A domain in the message is listed in Spameatingmonkey Fresh15 URIBL (registered in the past 15 days, .AERO,.BIZ,.COM,.INFO,.NAME,.NET,.PRO,.SK,.TEL,.US only)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_SBL_CSS", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus CSS" + }, + { + "symbol": "DM_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as belonging to a disposable email service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mime", + "rules": [ + { + "symbol": "MIME_BASE64_TEXT_BOGUS", + "weight": 1.0, + "description": "Has text part encoded in base64 that does not contain any 8bit characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CTYPE_MIXED_BOGUS", + "weight": 1.0, + "description": "multipart/mixed without non-textual part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CTYPE_MISSING_DISPOSITION", + "weight": 4.0, + "description": "Binary content-type not specified as an attachment", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BASE64_TEXT", + "weight": 0.100000, + "description": "Has text part encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "multimap", + "rules": [ + { + "symbol": "DISPOSABLE_FROM", + "weight": 0.0, + "description": "From a Disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_ENVFROM", + "weight": 0.0, + "description": "Envelope From is a Disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_TO", + "weight": 0.0, + "description": "To a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_REPLYTO", + "weight": 0.0, + "description": "Reply-To a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_CC", + "weight": 0.0, + "description": "To a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_TO", + "weight": 0.0, + "description": "To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_ENVRCPT", + "weight": 0.0, + "description": "Envelope Recipient is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_ENVFROM", + "weight": 0.0, + "description": "Envelope From is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_MDN", + "weight": 0.500000, + "description": "Disposition-Notification-To is a disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_MDN", + "weight": 0.0, + "description": "Disposition-Notification-To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_FROM", + "weight": 0.0, + "description": "From is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_REPLYTO", + "weight": 0.0, + "description": "Reply-To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DISPOSABLE_ENVRCPT", + "weight": 0.0, + "description": "Envelope Recipient is a Disposable e-mail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_CC", + "weight": 0.0, + "description": "To is a Freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REDIRECTOR_URL", + "weight": 0.0, + "description": "The presence of a redirector in the mail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "excessqp", + "rules": [ + { + "symbol": "CC_EXCESS_QP", + "weight": 1.200000, + "description": "Cc header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJ_EXCESS_QP", + "weight": 1.200000, + "description": "Subject header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EXCESS_QP", + "weight": 1.200000, + "description": "Reply-To header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_EXCESS_QP", + "weight": 1.200000, + "description": "From header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_EXCESS_QP", + "weight": 1.200000, + "description": "To header is unnecessarily encoded in quoted-printable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "upstream_spam_filters", + "rules": [ + { + "symbol": "UNITEDINTERNET_SPAM", + "weight": 5.0, + "description": "United Internet says this message is spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KLMS_SPAM", + "weight": 5.0, + "description": "Kaspersky Security for Mail Server says this message is spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MICROSOFT_SPAM", + "weight": 4.0, + "description": "Microsoft says the message is spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PRECEDENCE_BULK", + "weight": 0.0, + "description": "Message marked as bulk", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAM_FLAG", + "weight": 5.0, + "description": "Message was already marked as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "headers", + "rules": [ + { + "symbol": "FAKE_RECEIVED_smtp_yandex_ru", + "weight": 4.0, + "description": "Fake smtp.yandex.ru Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_RCONFIRM_MISMATCH", + "weight": 2.0, + "description": "Read confirmation address is different to from address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_ZERO", + "weight": 0.0, + "description": "No recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILER_1C_8", + "weight": 0.0, + "description": "Sent with 1C:Enterprise 8", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPTO_QUOTE_YAHOO", + "weight": 2.0, + "description": "Quoted Reply-To header from Yahoo (seems to be forged)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_SEVEN", + "weight": 0.0, + "description": "Message has 7-11 Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_ZERO", + "weight": 0.0, + "description": "Message has no Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPOOF_DISPLAY_NAME", + "weight": 8.0, + "description": "Display name is being used to spoof and trick the recipient", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_EQ_ADDR_ALL", + "weight": 0.0, + "description": "All of the recipients have display names that are the same as their address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_FROM", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_ENDS_EXCLAIM", + "weight": 0.0, + "description": "Subject ends with an exclamation mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_IMS", + "weight": 3.0, + "description": "Forged X-Mailer: Internet Mail Service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER", + "weight": 0.300000, + "description": "Sender is forged (different From: header and smtp MAIL FROM: addresses)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_ONE", + "weight": 0.0, + "description": "Message has one Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_RCPT_8BIT", + "weight": 6.0, + "description": "Invalid 8bit character in recipients headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THEBAT_BOUN", + "weight": 2.0, + "description": "Forged The Bat! MUA headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAIL_RU_MAILER", + "weight": 0.0, + "description": "Sent with Mail.Ru webmail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_CC_EMPTY_DELIMITER", + "weight": 1.0, + "description": "Cc header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "OLD_X_MAILER", + "weight": 2.0, + "description": "X-Mailer header has a very old MUA version", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED4", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FAKE_REPLY", + "weight": 1.0, + "description": "Fake reply", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "STRONGMAIL", + "weight": 6.0, + "description": "Sent via rogue \"strongmail\" MTA", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_FIVE", + "weight": 0.0, + "description": "Message has X-Priority header set to 5 or higher", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MIME_VERSION", + "weight": 2.0, + "description": "MIME-Version header is missing in MIME message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_RCVD", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_DOUBLE_IP_SPAM", + "weight": 2.0, + "description": "Has two Received headers containing bare IP addresses", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_REPLYTO", + "weight": 0.0, + "description": "Has Reply-To header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_MA_MISSING_HTML", + "weight": 1.0, + "description": "MIME multipart/alternative missing text/html part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DN_EQ_FROM_DN", + "weight": 0.0, + "description": "Reply-To display name matches From", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_EQ_TO_DOM", + "weight": 0.0, + "description": "Reply-To domain matches the To domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "X_PHPOS_FAKE", + "weight": 3.0, + "description": "Fake X-PHP-Originating-Script header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_VERP", + "weight": 0.0, + "description": "Envelope From is a VERP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_EQ_ENVFROM", + "weight": 0.0, + "description": "From address is the same as the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_ORG_HEADER", + "weight": 0.0, + "description": "Has Organization header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_TO", + "weight": 2.0, + "description": "To header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BROKEN_HEADERS", + "weight": 10.0, + "description": "Headers structure is likely broken", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_DN_EQ_ADDR", + "weight": 1.0, + "description": "From header display name is the same as the address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_REPLYTO_NEQ_FROM_DOM", + "weight": 3.0, + "description": "The From and Reply-To addresses in the email are from different freemail services", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_HELO_LOCALHOST", + "weight": 0.0, + "description": "Localhost HELO seen in Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_BAD_CTE_7BIT", + "weight": 3.500000, + "description": "Detects bad Content-Transfer-Encoding for text parts", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_FROM_EMPTY_DELIMITER", + "weight": 1.0, + "description": "From header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_HAS_QUESTION", + "weight": 0.0, + "description": "Subject contains a question mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_ZERO", + "weight": 0.0, + "description": "Message has X-Priority header set to 0", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_SOME", + "weight": 0.0, + "description": "Some of the recipients have display names", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ONCE_RECEIVED", + "weight": 0.100000, + "description": "One received header in a message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INFO_TO_INFO_LU", + "weight": 2.0, + "description": "info@ From/To address with List-Unsubscribe headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_EQ_FROM_DOM", + "weight": 0.0, + "description": "Reply-To domain matches the From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_MA_MISSING_TEXT", + "weight": 2.0, + "description": "MIME multipart/alternative missing text/plain part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_TWO", + "weight": 0.0, + "description": "Two recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_THREE", + "weight": 0.0, + "description": "3-5 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO", + "weight": 0.0, + "description": "X-Priority check callback rule", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_NONE", + "weight": 0.0, + "description": "None of the recipients have display names", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_TWO", + "weight": 0.0, + "description": "Message has two Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CTE_CASE", + "weight": 0.500000, + "description": "[78]Bit .vs. [78]bit", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_HAS_EXCLAIM", + "weight": 0.0, + "description": "Subject contains an exclamation mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_XM_UA", + "weight": 0.0, + "description": "Message has neither X-Mailer nor User-Agent header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "X_PHP_FORGED_0X", + "weight": 4.0, + "description": "X-PHP-Originating-Script header appears forged", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_IOS_MAILER", + "weight": 0.0, + "description": "Sent with Apple iPhone/iPad Mail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_LIST_UNSUB", + "weight": -0.010000, + "description": "Has List-Unsubscribe header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_INVALID", + "weight": 2.0, + "description": "Envelope from does not have a valid format", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED3", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_MIXED_CHARSET", + "weight": 5.0, + "description": "Mixed characters in a message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_MSGID", + "weight": 1.700000, + "description": "Message-ID header is incorrect", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_NEQ_FROM_DOM", + "weight": 0.0, + "description": "Reply-To domain does not match the From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_ENDS_SPACES", + "weight": 0.500000, + "description": "Subject ends with space characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_TWELVE", + "weight": 0.0, + "description": "Message has 12 or more Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NEQ_DISPLAY_NAME", + "weight": 4.0, + "description": "Display name contains an email address different to the From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BROKEN_CONTENT_TYPE", + "weight": 1.500000, + "description": "Message has part with broken content type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_DATE", + "weight": 1.0, + "description": "Date header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MSGID_YAHOO", + "weight": 2.0, + "description": "Forged Yahoo Message-ID header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_EQ_ADDR_SOME", + "weight": 0.0, + "description": "Some of the recipients have display names that are the same as their address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_RCVD_SPAMBOTS", + "weight": 3.0, + "description": "Spambots signatures in received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_MISSING_CHARSET", + "weight": 0.500000, + "description": "Charset header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MID", + "weight": 2.500000, + "description": "Message-ID header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_FORGED_MDN", + "weight": 2.0, + "description": "Read confirmation address is different to return path", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPOOF_REPLYTO", + "weight": 6.0, + "description": "Reply-To is being used to spoof and trick the recipient to send an off-domain reply", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_DATE_EMPTY_DELIMITER", + "weight": 1.0, + "description": "Date header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_MATCH_ENVRCPT_SOME", + "weight": 0.0, + "description": "Some of the recipients match the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS_MAILLIST", + "weight": 0.0, + "description": "Recipients are not the same as RCPT TO: mail command, but a message from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_FROM", + "weight": 2.0, + "description": "Missing From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_SEVEN", + "weight": 0.0, + "description": "7-11 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_UNPARSEABLE", + "weight": 1.0, + "description": "Reply-To header could not be parsed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_ONE", + "weight": 0.0, + "description": "Message has X-Priority header set to 1", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_GT_50", + "weight": 0.0, + "description": "50+ recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_TLS_LAST", + "weight": 0.0, + "description": "Last hop used encrypted transports", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NAME_HAS_TITLE", + "weight": 1.0, + "description": "From header display name has a title (Mr/Mrs/Dr)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PREVIOUSLY_DELIVERED", + "weight": 0.0, + "description": "Message either to a list or was forwarded", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_HELO_USER", + "weight": 3.0, + "description": "HELO User spam pattern", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_X_MAILER", + "weight": 4.500000, + "description": "Forged X-Mailer header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_HTTP_URL_IN_FROM", + "weight": 5.0, + "description": "HTTP URL preceded by the start of a line, quote, or whitespace, with normal or URL-encoded colons in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DOM_EQ_FROM_DOM", + "weight": 0.0, + "description": "To domain is the same as the From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_TWELVE", + "weight": 0.0, + "description": "12-50 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_OUTLOOK_TAGS", + "weight": 2.100000, + "description": "Message pretends to be send from Outlook but has 'strange' tags", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NO_DN", + "weight": 0.0, + "description": "From header does not have a display name", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_DATE", + "weight": 1.500000, + "description": "Malformed Date header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_NO_SPACE_IN_FROM", + "weight": 1.0, + "description": "No space in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_OUTLOOK_HTML", + "weight": 5.0, + "description": "Forged Outlook HTML signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_DISPLAY_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_ADDR_EQ_FROM", + "weight": 0.0, + "description": "Reply-To header is identical to SMTP From", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_MAILLIST", + "weight": 0.0, + "description": "Sender is not the same as MAIL FROM: envelope, but a message is from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_WRAPPED_IN_SPACES", + "weight": 2.0, + "description": "To address is wrapped in spaces inside angle brackets (e.g. display-name < local-part@domain >)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DIRECT_TO_MX", + "weight": 0.0, + "description": "Message has been directly delivered from MUA to local MX", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_FIVE", + "weight": 0.0, + "description": "Message has 5-7 Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_ENDS_QUESTION", + "weight": 1.0, + "description": "Subject ends with a question mark", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS", + "weight": 2.0, + "description": "Recipients are not the same as RCPT TO: mail command", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TRACKER_ID", + "weight": 3.840000, + "description": "Spam string at the end of message to make statistics fault", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NEQ_ENVFROM", + "weight": 0.0, + "description": "From address is different to the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_EXTRA_SEMI", + "weight": 1.0, + "description": "Content-Type header ends with a semi-colon", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILLIST", + "weight": -0.200000, + "description": "Message seems to be from maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_TWO", + "weight": 0.0, + "description": "Message has X-Priority header set to 2", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_FIVE", + "weight": 0.0, + "description": "5-7 recipients", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_SUBJECT", + "weight": 2.0, + "description": "Subject header is missing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CD_MM_BODY", + "weight": 2.0, + "description": "Content-Description header reads \"Mail message body\", commonly seen in spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "YANDEX_RU_MAILER", + "weight": 0.0, + "description": "Sent with Yandex webmail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "GOOGLE_FORWARDING_MID_MISSING", + "weight": 2.500000, + "description": "Message was missing Message-ID pre-forwarding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_NEEDS_ENCODING", + "weight": 1.0, + "description": "To header needs encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NEEDS_ENCODING", + "weight": 1.0, + "description": "From header needs encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_NEEDS_ENCODING", + "weight": 1.0, + "description": "Subject needs encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EQ_TO_ADDR", + "weight": 5.0, + "description": "Reply-To is the same as the To address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EMAIL_HAS_TITLE", + "weight": 2.0, + "description": "Reply-To header has title", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCPT_COUNT_ONE", + "weight": 0.0, + "description": "One recipient", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_EQ_FROM", + "weight": 0.0, + "description": "To address matches the From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_MIME", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_RECIPS", + "weight": 1.500000, + "description": "Recipients seems to be autogenerated (works if recipients count is more than 5)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FAKE_RECEIVED_mail_ru", + "weight": 4.0, + "description": "Fake HELO mail.ru in Received header from non-mail.ru sender address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_XOIP", + "weight": 0.0, + "description": "Has X-Originating-IP header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_DOM_NEQ_TO_DOM", + "weight": 0.0, + "description": "Reply-To domain does not match the To domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EMPTY_SUBJECT", + "weight": 1.0, + "description": "Subject header is empty", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "STOX_REPLY_TYPE", + "weight": 1.0, + "description": "Reply-type in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_HEADER_CTYPE_ONLY", + "weight": 2.0, + "description": "Only Content-Type header without other MIME headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BOUNCE", + "weight": -0.100000, + "description": "(Non) Delivery Status Notification", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SORTED_RECIPS", + "weight": 3.500000, + "description": "Recipients list seems to be sorted", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_POSTFIX_RECEIVED", + "weight": 3.0, + "description": "Invalid Postfix Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_PRVS", + "weight": 0.0, + "description": "Envelope From is a PRVS address that matches the From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_RECEIVED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MIMEOLE", + "weight": 2.0, + "description": "Mime-OLE is needed but absent (e.g. fake Outlook or fake Exchange)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_HAS_DN", + "weight": 0.0, + "description": "From header has a display name", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_NO_TLS_LAST", + "weight": 0.100000, + "description": "Last hop did not use encrypted transports", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_FROM_8BIT", + "weight": 6.0, + "description": "Invalid 8bit character in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RATWARE_MS_HASH", + "weight": 2.0, + "description": "Forged Exchange messages", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ONCE_RECEIVED_STRICT", + "weight": 4.0, + "description": "One received header with 'bad' patterns inside", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "XM_CASE", + "weight": 0.500000, + "description": "X-mailer .vs. X-Mailer", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DATE_IN_PAST", + "weight": 1.0, + "description": "Message date is in the past", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MULTIPLE_UNIQUE_HEADERS", + "weight": 7.0, + "description": "Repeated unique headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PRIO_THREE", + "weight": 0.0, + "description": "Message has X-Priority header set to 3 or 4", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_REPLYTO", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_MIXED_CHARSET_URL", + "weight": 7.0, + "description": "Mixed characters in a URL inside message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MV_CASE", + "weight": 0.500000, + "description": "Mime-Version .vs. MIME-Version", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_UNDISC_RCPT", + "weight": 3.0, + "description": "Recipients are absent or undisclosed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_MAILER", + "weight": 0.0, + "description": "Sent with Apple Mail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_ALL", + "weight": 0.0, + "description": "All the recipients have display names", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "GOOGLE_FORWARDING_MID_BROKEN", + "weight": 1.700000, + "description": "Message had invalid Message-ID pre-forwarding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_INVALID", + "weight": 2.0, + "description": "From header does not have a valid format", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DATE_IN_FUTURE", + "weight": 4.0, + "description": "Message date is in the future", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_NAME_EXCESS_SPACE", + "weight": 1.0, + "description": "From header display name contains excess whitespace", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_GENERIC_RECEIVED2", + "weight": 3.600000, + "description": "Forged generic Received header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_COUNT_THREE", + "weight": 0.0, + "description": "Message has 3-5 Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EQ_FROM", + "weight": 0.0, + "description": "Reply-To header is identical to From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MULTIPLE_FROM", + "weight": 8.0, + "description": "Multiple addresses in From header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_CD_HEADER", + "weight": 0.0, + "description": "Has Content-Description header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_TLS_ALL", + "weight": 0.0, + "description": "All hops used encrypted transports", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_MATCH_ENVRCPT_ALL", + "weight": 0.0, + "description": "All of the recipients match the envelope", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_VIA_SMTP_AUTH", + "weight": 0.0, + "description": "Authenticated hand-off was seen in Received headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_DN_RECIPIENTS", + "weight": 2.0, + "description": "To header display name is \"Recipients\"", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_HTML_ONLY", + "weight": 0.200000, + "description": "Message has only an HTML part", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_INTERSPIRE_SIG", + "weight": 1.0, + "description": "Has Interspire fingerprint", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJECT_HAS_CURRENCY", + "weight": 1.0, + "description": "Subject contains currency", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJ_BOUNCE_WORDS", + "weight": 0.0, + "description": "Words/phrases typical for DSN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_REPLYTO_EMPTY_DELIMITER", + "weight": 1.0, + "description": "Reply-To header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HEADER_TO_EMPTY_DELIMITER", + "weight": 1.0, + "description": "To header has no delimiter between header name and header value", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "phishing", + "rules": [ + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HACKED_WP_PHISHING", + "weight": 4.500000, + "description": "Phish message sent by hacked Wordpress instance", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_REDIRECTOR_NESTED", + "weight": 1.0, + "description": "URL redirector nested limit has been reached" + }, + { + "symbol": "REDIRECTOR_FALSE", + "weight": 0.0, + "description": "Phishing exclusion symbol for known redirectors", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_EXCLUDED", + "weight": 0.0, + "description": "Phished URL found in exclusions list", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHING", + "weight": 4.0, + "description": "Phished URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_OPENPHISH", + "weight": 7.0, + "description": "Phished URL found in openphish.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_GENERIC_SERVICE", + "weight": 0.0, + "description": "Phished URL found in generic service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_WHITELISTED", + "weight": 0.0, + "description": "Phishing exclusion symbol for known exceptions", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISHED_PHISHTANK", + "weight": 7.0, + "description": "Phished URL found in phishtank.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "excessb64", + "rules": [ + { + "symbol": "FROM_EXCESS_BASE64", + "weight": 1.500000, + "description": "From header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REPLYTO_EXCESS_BASE64", + "weight": 1.500000, + "description": "Reply-To header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TO_EXCESS_BASE64", + "weight": 1.500000, + "description": "To header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CC_EXCESS_BASE64", + "weight": 1.500000, + "description": "Cc header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUBJ_EXCESS_BASE64", + "weight": 1.500000, + "description": "Subject header is unnecessarily encoded in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "forwarding", + "rules": [ + { + "symbol": "FWD_MAILRU", + "weight": 0.0, + "description": "Message was forwarded by Mail.ru", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORWARDED", + "weight": 0.0, + "description": "Message was forwarded", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_GOOGLE", + "weight": 0.0, + "description": "Message was forwarded by Google", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_SIEVE", + "weight": 0.0, + "description": "Message was forwarded using Sieve", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_CPANEL", + "weight": 0.0, + "description": "Message was forwarded using cPanel", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_YANDEX", + "weight": 0.0, + "description": "Message was forwarded by Yandex", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FWD_SRS", + "weight": 0.0, + "description": "Message was forwarded using Sender Rewriting Scheme (SRS)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "url", + "rules": [ + { + "symbol": "HAS_FILE_URL", + "weight": 2.0, + "description": "Contains file:// URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_BAD_UNICODE", + "weight": 3.0, + "description": "URL contains invalid Unicode", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_USER_PASSWORD", + "weight": 2.0, + "description": "URL contains user field", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_OBFUSCATED_TEXT", + "weight": 5.0, + "description": "Obfuscated URL found in message text", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_VERY_LONG", + "weight": 1.500000, + "description": "URL is very long", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_HOMOGRAPH_ATTACK", + "weight": 5.0, + "description": "URL uses homograph attack (mixed scripts)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_SUSPICIOUS_TLD", + "weight": 3.0, + "description": "URL uses suspicious TLD", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_GOOGLE_REDIR", + "weight": 1.0, + "description": "Has google.com/url or alike Google redirection URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URI_COUNT_ODD", + "weight": 1.0, + "description": "Odd number of URIs in multipart/alternative message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_ZERO_WIDTH_SPACES", + "weight": 7.0, + "description": "URL contains zero-width spaces", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_USER_LONG", + "weight": 3.0, + "description": "URL user field is long (>128 chars)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_GOOGLE_FIREBASE_URL", + "weight": 2.0, + "description": "Contains firebasestorage.googleapis.com URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_IPFS_GATEWAY_URL", + "weight": 6.0, + "description": "Message contains InterPlanetary File System (IPFS) gateway URL, likely malicious", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_RTL_OVERRIDE", + "weight": 6.0, + "description": "URL uses RTL override character", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NUMERIC_PRIVATE_IP", + "weight": 0.500000, + "description": "URL uses private IP range", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_BACKSLASH_PATH", + "weight": 2.0, + "description": "URL uses backslashes", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NUMERIC_IP", + "weight": 1.500000, + "description": "URL uses numeric IP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_USER_VERY_LONG", + "weight": 5.0, + "description": "URL user field is very long (>256 chars)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_ONION_URI", + "weight": 0.0, + "description": "Contains .onion hidden service URI", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_EXCESSIVE_DOTS", + "weight": 2.0, + "description": "URL has excessive dots in hostname", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_SUSPECT_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NO_TLD", + "weight": 2.0, + "description": "URL has no TLD", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "OMOGRAPH_URL", + "weight": 5.0, + "description": "URL contains both latin and non-latin characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_MULTIPLE_AT_SIGNS", + "weight": 3.0, + "description": "URL has multiple @ signs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_NUMERIC_IP_USER", + "weight": 4.0, + "description": "URL uses numeric IP with user field", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_GUC_PROXY_URI", + "weight": 1.0, + "description": "Has googleusercontent.com proxy URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "rspamdbl", + "rules": [ + { + "symbol": "RSPAMD_URIBL", + "weight": 4.500000, + "description": "Rspamd uribl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL", + "weight": 2.500000, + "description": "Rspamd emailbl, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "blocked", + "rules": [ + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_BLOCKED", + "weight": 0.0, + "description": "https://www.dnswl.org: Resolver blocked due to excessive queries (DWL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243" + }, + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "blocklistde", + "rules": [ + { + "symbol": "RECEIVED_BLOCKLISTDE", + "weight": 3.0, + "description": "Received address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_BLOCKLISTDE", + "weight": 4.0, + "description": "From address is listed in Blocklist (https://www.blocklist.de/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mime_types", + "rules": [ + { + "symbol": "MIME_DOUBLE_BAD_EXTENSION", + "weight": 3.0, + "description": "Bad extension cloaking", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_TRACE", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_ARCHIVE_IN_ARCHIVE", + "weight": 5.0, + "description": "Archive within another archive", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_UNKNOWN", + "weight": 0.100000, + "description": "Missing or unknown content-type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENCRYPTED_PGP", + "weight": -0.500000, + "description": "Message is encrypted with PGP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_GOOD", + "weight": -0.100000, + "description": "Known content-type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BOGUS_ENCRYPTED_AND_TEXT", + "weight": 10.0, + "description": "Bogus mix of encrypted and text/html payloads", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_EXTENSION", + "weight": 2.0, + "description": "Bad extension", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_EXE_IN_GEN_SPLIT_RAR", + "weight": 5.0, + "description": "EXE file in RAR archive with generic split extension (e.g. .001)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_ENCRYPTED_ARCHIVE", + "weight": 2.0, + "description": "Encrypted archive in a message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD", + "weight": 1.0, + "description": "Known bad content-type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SIGNED_SMIME", + "weight": -2.0, + "description": "Message is signed with S/MIME", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_TYPES_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_UNICODE", + "weight": 2.0, + "description": "Filename with known obscured unicode characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SIGNED_PGP", + "weight": -2.0, + "description": "Message is signed with PGP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_OBFUSCATED_ARCHIVE", + "weight": 2.0, + "description": "Archive has files with clear obfuscation signs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENCRYPTED_SMIME", + "weight": -0.500000, + "description": "Message is encrypted with S/MIME", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_ATTACHMENT", + "weight": 4.0, + "description": "Invalid attachment mime type", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "antivirus", + "rules": [] + }, + { + "group": "spf", + "rules": [ + { + "symbol": "R_SPF_FAIL", + "weight": 1.0, + "description": "SPF verification failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF_DKIM", + "weight": -3.0, + "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF_DKIM", + "weight": 3.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PERMFAIL", + "weight": 0.0, + "description": "SPF record is malformed or persistent DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_ALLOW", + "weight": -0.200000, + "description": "SPF verification allows sending", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_SOFTFAIL", + "weight": 0.0, + "description": "SPF verification soft-failed", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_NEUTRAL", + "weight": 0.0, + "description": "SPF policy is neutral", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_PLUSALL", + "weight": 4.0, + "description": "SPF record allows to send from any IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_DNSFAIL", + "weight": 0.0, + "description": "SPF DNS failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SPF_NA", + "weight": 0.0, + "description": "Missing SPF record", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF", + "weight": 1.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "hfilter", + "rules": [ + { + "symbol": "HFILTER_URL_ONELINE", + "weight": 2.500000, + "description": "One line URL and text in body", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_3", + "weight": 2.0, + "description": "Helo host checks (medium)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_1", + "weight": 0.500000, + "description": "Hostname checks (very low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_4", + "weight": 2.500000, + "description": "Helo host checks (hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_BAREIP", + "weight": 3.0, + "description": "Helo host is bare ip", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_4", + "weight": 2.500000, + "description": "Hostname checks (hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_1", + "weight": 0.500000, + "description": "Helo host checks (very low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_5", + "weight": 3.0, + "description": "Helo host checks (very hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_NORESOLVE_MX", + "weight": 0.200000, + "description": "MX found in Helo and no resolve", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_3", + "weight": 2.0, + "description": "Hostname checks (medium)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_RCPT_BOUNCEMOREONE", + "weight": 1.500000, + "description": "Message from bounce and over 1 recipient", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROMHOST_NORES_A_OR_MX", + "weight": 1.500000, + "description": "FROM host no resolve to A or MX", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_2", + "weight": 1.0, + "description": "Helo host checks (low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_BADIP", + "weight": 4.500000, + "description": "Helo host is very bad ip", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_2", + "weight": 1.0, + "description": "Hostname checks (low)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_5", + "weight": 3.0, + "description": "Hostname checks (very hard)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROM_BOUNCE", + "weight": 0.0, + "description": "Bounce message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RDNS_DNSFAIL", + "weight": 0.0, + "description": "PTR verification DNS error", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_NOT_FQDN", + "weight": 2.0, + "description": "Helo not FQDN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_NORES_A_OR_MX", + "weight": 0.300000, + "description": "Helo no resolve to A or MX", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROMHOST_NORESOLVE_MX", + "weight": 0.500000, + "description": "MX found in FROM host and no resolve", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_FROMHOST_NOT_FQDN", + "weight": 3.0, + "description": "FROM host not FQDN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HOSTNAME_UNKNOWN", + "weight": 2.500000, + "description": "Unknown client hostname (PTR or FCrDNS verification failed)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RDNS_NONE", + "weight": 2.0, + "description": "Cannot resolve reverse DNS for sender's IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_HELO_IP_A", + "weight": 1.0, + "description": "Helo A IP != hostname IP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HFILTER_URL_ONLY", + "weight": 2.200000, + "description": "URL only in body", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "spamhaus", + "rules": [ + { + "symbol": "RBL_SPAMHAUS_DROP", + "weight": 7.0, + "description": "From address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_PBL", + "weight": 2.0, + "description": "From address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_BOTNET", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PROHIBIT", + "weight": 0.0, + "description": "DBL uribl IP queries prohibited!", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAMHAUS_ZEN_URIBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN URIBL" + }, + { + "symbol": "RBL_SPAMHAUS", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus ZEN", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED", + "weight": 0.0, + "description": "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BOTNET", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as botnet C&C", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_PBL", + "weight": 0.0, + "description": "Received address is listed in Spamhaus PBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_SBL", + "weight": 6.500000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus SBL" + }, + { + "symbol": "RBL_SPAMHAUS_SBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_SBL", + "weight": 3.0, + "description": "Received address is listed in Spamhaus SBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_REDIR", + "weight": 5.0, + "description": "A domain in the message is listed in Spamhaus DBL as spammed redirector domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_CSS", + "weight": 2.0, + "description": "From address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_PHISH", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit phish", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_XBL", + "weight": 1.0, + "description": "Received address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_SPAM", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as spam", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_PBL", + "weight": 0.010000, + "description": "A domain in the message body resolves to an IP listed in Spamhaus PBL" + }, + { + "symbol": "URIBL_DROP", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus DROP" + }, + { + "symbol": "RECEIVED_SPAMHAUS_CSS", + "weight": 1.0, + "description": "Received address is listed in Spamhaus CSS", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_PHISH", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_ABUSE_MALWARE", + "weight": 6.500000, + "description": "A domain in the message is listed in Spamhaus DBL as abused legit malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SPAMHAUS_XBL", + "weight": 4.0, + "description": "From address is listed in Spamhaus XBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL", + "weight": 0.0, + "description": "Unrecognised result from Spamhaus DBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_MALWARE", + "weight": 7.500000, + "description": "A domain in the message is listed in Spamhaus DBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RECEIVED_SPAMHAUS_BLOCKED_OPENRESOLVER", + "weight": 0.0, + "description": "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_XBL", + "weight": 3.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus XBL" + }, + { + "symbol": "URIBL_SBL_CSS", + "weight": 5.0, + "description": "A domain in the message body resolves to an IP listed in Spamhaus CSS" + }, + { + "symbol": "RECEIVED_SPAMHAUS_DROP", + "weight": 6.0, + "description": "Received address is listed in Spamhaus DROP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "ebl", + "rules": [ + { + "symbol": "MSBL_EBL", + "weight": 7.500000, + "description": "MSBL emailbl (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL_GREY", + "weight": 0.500000, + "description": "MSBL emailbl grey list (https://www.msbl.org/)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "surblorg", + "rules": [ + { + "symbol": "CRACKED_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as cracked", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_BLOCKED", + "weight": 0.0, + "description": "SURBL: query blocked by policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PH_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as phishing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_SURBL", + "weight": 5.0, + "description": "A domain in the message is listed in SURBL as abused", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CT_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as a clicktracker", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MW_SURBL_MULTI", + "weight": 7.500000, + "description": "A domain in the message is listed in SURBL as malware", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DM_SURBL", + "weight": 0.0, + "description": "A domain in the message is listed in SURBL as belonging to a disposable email service", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "uribl", + "rules": [ + { + "symbol": "URIBL_GREY", + "weight": 2.500000, + "description": "A domain in the message is listed in URIBL.com grey", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI", + "weight": 0.0, + "description": "Unrecognised result from URIBL.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLOCKED", + "weight": 0.0, + "description": "URIBL.com: query refused, likely due to policy/overusage", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_BLACK", + "weight": 7.500000, + "description": "A domain in the message is listed in URIBL.com black", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_RED", + "weight": 0.500000, + "description": "A domain in the message is listed in URIBL.com red", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "external_services", + "rules": [] + }, + { + "group": "experimental", + "rules": [ + { + "symbol": "XM_UA_NO_VERSION", + "weight": 0.010000, + "description": "X-Mailer/User-Agent header has no version number", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "composite", + "rules": [ + { + "symbol": "SUSPICIOUS_AUTH_ORIGIN", + "weight": 0.0, + "description": "Message authenticated, but from a suspicios origin (potentially an injector)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS_FORWARDING", + "weight": 0.0, + "description": "FORGED_RECIPIENTS & g:forwarding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "UNDISC_RCPTS_BULK", + "weight": 3.0, + "description": "Missing or undisclosed recipients with a bulk signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_URL_IN_SUSPICIOUS_MESSAGE", + "weight": 1.0, + "description": "Message contains redirector, anonymous or IPFS gateway URL and is marked by fuzzy/bayes/SURBL/RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_UNAUTH_PBL", + "weight": 2.0, + "description": "Relayed through Spamhaus PBL IP without sufficient authentication (possibly indicating an open relay)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_MAILER_COMMON", + "weight": 0.0, + "description": "Message was sent by 'Apple Mail' and has common symbols in place", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_MAILLIST", + "weight": 0.0, + "description": "Sender is not the same as MAIL FROM: envelope, but a message is from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHISH_EMOTION", + "weight": 1.0, + "description": "Phish message with subject trying to address users emotion", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DMARC_POLICY_ALLOW_WITH_FAILURES", + "weight": -0.500000, + "description": "DMARC permit policy with DKIM/SPF failure", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "AUTH_NA_OR_FAIL", + "weight": 1.0, + "description": "No authenticating method SPF/DKIM/DMARC/ARC was successful", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "REDIRECTOR_URL_ONLY", + "weight": 1.0, + "description": "Message only contains a redirector URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_RECIPIENTS_MAILLIST", + "weight": 0.0, + "description": "Recipients are not the same as RCPT TO: mail command, but a message from a maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_VERP_SRS", + "weight": 0.0, + "description": "FORGED_SENDER & (ENVFROM_PRVS | ENVFROM_VERP)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_ANON_DOMAIN", + "weight": 0.100000, + "description": "Contains one or more domains trying to disguise owner/destination", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BROKEN_HEADERS_MAILLIST", + "weight": 0.0, + "description": "Negate BROKEN_HEADERS when message comes via some mailing list", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "AUTOGEN_PHP_SPAMMY", + "weight": 1.0, + "description": "Message was generated by PHP script and contains some spam indicators", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "APPLE_IOS_MAILER_COMMON", + "weight": 0.0, + "description": "Message was sent by 'Apple iOS Mail' and has common symbols in place", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "IP_SCORE_FREEMAIL", + "weight": 0.0, + "description": "Negate IP_SCORE when message comes from FreeMail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "VIOLATED_DIRECT_SPF", + "weight": 3.500000, + "description": "Has no Received (or no trusted received relays) and SPF policy fails or soft fails", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "AUTH_NA", + "weight": 1.0, + "description": "Authenticating message via SPF/DKIM/DMARC/ARC not available", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_REPLYTO_NEQ_FROM", + "weight": 2.0, + "description": "Reply-To is a Freemail address and it not match From header or SMTP From, also From is not another Freemail", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_EXT_IN_OBFUSCATED_ARCHIVE", + "weight": 8.0, + "description": "Attachment with bad extension and archive that has filename with clear obfuscation signs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BAD_REP_POLICIES", + "weight": 0.100000, + "description": "Contains valid policies but are also marked by fuzzy/bayes/SURBL/RBL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_MID_ALLOWED", + "weight": 0.0, + "description": "MISSING_MID_ALLOWED", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MAILLIST", + "weight": 0.0, + "description": "Avoid false positives for FORGED_MUA_* in maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_FAIL_FORWARDING", + "weight": 0.0, + "description": "g:forwarding & (R_SPF_SOFTFAIL | R_SPF_FAIL)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INVALID_MSGID_ALLOWED", + "weight": 0.0, + "description": "INVALID_MSGID_ALLOWED", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_DKIM_ARC_DNSWL_HI", + "weight": -1.0, + "description": "Sufficiently DKIM/ARC signed and received from IP with high trust at DNSWL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_SENDER_FORWARDING", + "weight": 0.0, + "description": "Forged sender, but message is forwarded", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MIME_BAD_EXT_WITH_BAD_UNICODE", + "weight": 8.0, + "description": "Attachment with bad extension and filename that has known obscured unicode characters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_DKIM_ARC_DNSWL_MED", + "weight": -0.500000, + "description": "Sufficiently DKIM/ARC signed and received from IP with medium trust at DNSWL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_MIXED", + "weight": 0.0, + "description": "-R_DKIM_ALLOW & (R_DKIM_TEMPFAIL | R_DKIM_PERMFAIL | R_DKIM_REJECT)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BOUNCE_NO_AUTH", + "weight": 1.0, + "description": "(AUTH_NA | AUTH_NA_OR_FAIL) & (BOUNCE | SUBJ_BOUNCE_WORDS)", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mid", + "rules": [ + { + "symbol": "MID_END_EQ_FROM_USER_PART", + "weight": 4.0, + "description": "Message-ID RHS (after @) and MIME from local part are the same", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "CHECK_MID", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KNOWN_MID", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KNOWN_NO_MID", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "KNOWN_MID_CALLBACK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "fuzzy", + "rules": [ + { + "symbol": "FUZZY_DENIED", + "weight": 12.0, + "description": "Denied fuzzy hash, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_PROB", + "weight": 5.0, + "description": "Probable fuzzy hash, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_ENCRYPTION_REQUIRED", + "weight": 0.0, + "description": "Fuzzy encryption is required by a server", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_WHITE", + "weight": -2.100000, + "description": "Whitelisted fuzzy hash, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_FORBIDDEN", + "weight": 0.0, + "description": "Fuzzy access denied", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_RATELIMITED", + "weight": 0.0, + "description": "Fuzzy rate limit is reached", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_UNKNOWN", + "weight": 5.0, + "description": "Generic fuzzy hash match, bl.rspamd.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FUZZY_CALLBACK", + "weight": 0.0, + "description": "Fuzzy check callback", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "senderscore", + "rules": [ + { + "symbol": "RBL_SENDERSCORE_NA", + "weight": 0.0, + "description": "From address is listed in SenderScore RPBL - noauth" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_2", + "weight": 3.0, + "description": "SenderScore Reputation: Bad (20-29).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_SCORE", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_9", + "weight": -1.0, + "description": "SenderScore Reputation: Good (90-100).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_4", + "weight": 2.0, + "description": "SenderScore Reputation: Bad (40-49).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_1", + "weight": 3.500000, + "description": "SenderScore Reputation: Bad (10-19).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN", + "weight": 0.0, + "description": "Unrecognized result from SenderScore Reputation list.", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - sender_score+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_8", + "weight": 0.0, + "description": "SenderScore Reputation: Neutral (80-89).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST_NA", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_NA_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_PRST_BOT", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - pristine+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_SUS_ATT_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - sender_score+suspect_attachments+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_6", + "weight": 1.0, + "description": "SenderScore Reputation: Bad (60-69).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_PRST", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - pristine" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_0", + "weight": 4.0, + "description": "SenderScore Reputation: Very Bad (0-9).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments" + }, + { + "symbol": "RBL_SENDERSCORE_SCORE_PRST", + "weight": 4.0, + "description": "From address is listed in SenderScore RPBL - sender_score+pristine" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_3", + "weight": 2.500000, + "description": "SenderScore Reputation: Bad (30-39).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_5", + "weight": 1.500000, + "description": "SenderScore Reputation: Bad (50-59).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA", + "weight": 3.0, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth" + }, + { + "symbol": "RBL_SENDERSCORE_NA_BOT", + "weight": 1.0, + "description": "From address is listed in SenderScore RPBL - noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_7", + "weight": 0.500000, + "description": "SenderScore Reputation: Bad (70-79).", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_NA_BOT", + "weight": 1.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_SUS_ATT_PRST_NA_BOT", + "weight": 3.500000, + "description": "From address is listed in SenderScore RPBL - suspect_attachments+pristine+noauth+botnet" + }, + { + "symbol": "RBL_SENDERSCORE_BOT", + "weight": 2.0, + "description": "From address is listed in SenderScore RPBL - botnet" + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_BLOCKED", + "weight": 0.0, + "description": "Excessive number of queries to SenderScore RPBL, more info: https://knowledge.validity.com/hc/en-us/articles/20961730681243", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "aliases", + "rules": [ + { + "symbol": "TAGGED_RCPT", + "weight": 0.0, + "description": "Recipient has plus-tags", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "TAGGED_FROM", + "weight": 0.0, + "description": "From address has plus-tags", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INTERNAL_MAIL", + "weight": 0.0, + "description": "Mail from local to local domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ALIASES_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LOCAL_INBOUND", + "weight": 0.0, + "description": "Mail from external to local domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ALIAS_RESOLVED", + "weight": 0.0, + "description": "Address was resolved through aliases", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LOCAL_OUTBOUND", + "weight": 0.0, + "description": "Mail from local to external domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "malware", + "rules": [ + { + "symbol": "EXE_ARCHIVE_CLICKBAIT_FILENAME", + "weight": 9.0, + "description": "exe file in archive with clickbait filename", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXE_ARCHIVE_CLICKBAIT_SUBJECT", + "weight": 9.0, + "description": "exe file in archive with clickbait subject", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISIDENTIFIED_RAR", + "weight": 4.0, + "description": "rar with wrong extension", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXE_IN_ARCHIVE", + "weight": 1.500000, + "description": "exe file in archive", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXE_IN_MISIDENTIFIED_RAR", + "weight": 5.0, + "description": "rar with wrong extension containing exe file", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SINGLE_FILE_ARCHIVE_WITH_EXE", + "weight": 5.0, + "description": "single file container bearing executable", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mailspike", + "rules": [ + { + "symbol": "MAILSPIKE", + "weight": 0.0, + "description": "Unrecognised result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_BAD", + "weight": 1.0, + "description": "From address is listed in Mailspike RBL - bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_VERYBAD", + "weight": 1.500000, + "description": "From address is listed in Mailspike RBL - very bad reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_GOOD", + "weight": -0.100000, + "description": "From address is listed in Mailspike RWL - good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_VERYGOOD", + "weight": -0.200000, + "description": "From address is listed in Mailspike RWL - very good reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_POSSIBLE", + "weight": 0.0, + "description": "From address is listed in Mailspike RWL - possibly legit", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_EXCELLENT", + "weight": -0.400000, + "description": "From address is listed in Mailspike RWL - excellent reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RWL_MAILSPIKE_NEUTRAL", + "weight": 0.0, + "description": "Neutral result from Mailspike", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_MAILSPIKE_WORST", + "weight": 2.0, + "description": "From address is listed in Mailspike RBL - worst possible reputation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "compromised_hosts", + "rules": [ + { + "symbol": "URI_HIDDEN_PATH", + "weight": 1.0, + "description": "Message contains URI with a hidden path", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "XAW_SERVICE_ACCT", + "weight": 1.0, + "description": "Message originally from a service account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HIDDEN_SOURCE_OBJ", + "weight": 2.0, + "description": "UNIX hidden file/directory in path", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_PHPMAILER_SIG", + "weight": 0.0, + "description": "PHPMailer signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WWW_DOT_DOMAIN", + "weight": 0.500000, + "description": "From/Sender/Reply-To or Envelope is @www.domain.com", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_SOURCE", + "weight": 0.0, + "description": "Has X-Source headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HACKED_WP_PHISHING", + "weight": 4.500000, + "description": "Phish message sent by hacked Wordpress instance", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_XAW", + "weight": 0.0, + "description": "Has X-Authentication-Warning header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_PHP_SCRIPT", + "weight": 0.0, + "description": "Has X-PHP-Script header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHP_SCRIPT_ROOT", + "weight": 1.0, + "description": "PHP Script executed by root UID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PHP_XPS_PATTERN", + "weight": 0.0, + "description": "Message contains X-PHP-Script pattern", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_AS", + "weight": 0.0, + "description": "Has X-Authenticated-Sender header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "COMPROMISED_ACCT_BULK", + "weight": 3.0, + "description": "Likely to be from a compromised account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "X_PHP_EVAL", + "weight": 4.0, + "description": "Message sent using eval'd PHP", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_POS", + "weight": 0.0, + "description": "Has X-PHP-Originating-Script header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_WP_URI", + "weight": 0.0, + "description": "Contains WordPress URIs", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ABUSE_FROM_INJECTOR", + "weight": 2.0, + "description": "Message is sent from a suspicios origin and showing signs of abuse, likely spam injected in compromised account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_GMSV", + "weight": 0.0, + "description": "Has X-Get-Message-Sender-Via: header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FROM_SERVICE_ACCT", + "weight": 1.0, + "description": "Sender/From/Reply-To is a service account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ENVFROM_SERVICE_ACCT", + "weight": 1.0, + "description": "Envelope from is a service account", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_X_ANTIABUSE", + "weight": 0.0, + "description": "Has X-AntiAbuse headers", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WP_COMPROMISED", + "weight": 0.0, + "description": "URL that is pointing to a compromised WordPress installation", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_WWW", + "weight": 0.500000, + "description": "Message-ID from www host", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "html", + "rules": [ + { + "symbol": "ZERO_FONT", + "weight": 1.0, + "description": "Zero sized font used", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_SHORT_LINK_IMG_1", + "weight": 2.0, + "description": "Short HTML part (0..1K) with a link to an image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_WHITE_ON_WHITE", + "weight": 4.0, + "description": "Message contains low contrast text", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_SHORT_LINK_IMG_2", + "weight": 1.0, + "description": "Short HTML part (1K..1.5K) with a link to an image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_VISIBLE_CHECKS", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_SHORT_LINK_IMG_3", + "weight": 0.500000, + "description": "Short HTML part (1.5K..2K) with a link to an image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HAS_DATA_URI", + "weight": 0.0, + "description": "Has Data URI encoding", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTTP_TO_IP", + "weight": 1.0, + "description": "HTML anchor points to an IP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_EMPTY_IMAGE", + "weight": 2.0, + "description": "Message contains empty parts and image", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MANY_INVISIBLE_PARTS", + "weight": 1.0, + "description": "Many parts are visually hidden", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_SUSPICIOUS_IMAGES", + "weight": 5.0, + "description": "Message has high image to text ratio", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTTP_TO_HTTPS", + "weight": 0.500000, + "description": "The anchor text contains a distinct scheme compared to the target URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "EXT_CSS", + "weight": 1.0, + "description": "Message contains external CSS reference", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DATA_URI_OBFU", + "weight": 2.0, + "description": "Uses Data URI encoding to obfuscate plain or HTML in base64", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "HTML_META_REFRESH_URL", + "weight": 5.0, + "description": "Has HTML Meta refresh URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "subject", + "rules": [ + { + "symbol": "SUBJ_ALL_CAPS", + "weight": 3.0, + "description": "Subject contains mostly capital letters", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LONG_SUBJ", + "weight": 3.0, + "description": "Subject is very long", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URL_IN_SUBJECT", + "weight": 4.0, + "description": "Subject contains URL", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "ungrouped", + "rules": [ + { + "symbol": "ARC_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ASN", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DKIM_SIGNED", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLOCKLISTDE_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DWL_DNSWL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MSBL_EBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MAILSPIKE_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPAMHAUS_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_FRESH15_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SPF_CHECK", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_HASHBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RCVD_IN_DNSWL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SINGLE_SHORT_PART", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SURBL_MULTI_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "UDF_COMPRESSION_500PLUS", + "weight": 9.0, + "description": "very well compressed img file in archive", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "ASN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_VIRUSFREE_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SENDERSCORE_REPUT_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_EMAILBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "URIBL_MULTI_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "DBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RSPAMD_URIBL_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "RBL_SEM_IPV6_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SEM_URIBL_UNKNOWN_FAIL", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "mua", + "rules": [ + { + "symbol": "FORGED_MUA_THEBAT_MSGID_UNKNOWN", + "weight": 3.0, + "description": "Message pretends to be send from The Bat! but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_KMAIL_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Message pretends to be send from KMail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_OPERA_MSGID", + "weight": 4.0, + "description": "Message pretends to be send from Opera Mail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_SEAMONKEY_MSGID", + "weight": 4.0, + "description": "Forged mail pretending to be from Mozilla Seamonkey but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_SEAMONKEY_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Forged mail pretending to be from Mozilla Seamonkey but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_OUTLOOK", + "weight": 3.0, + "description": "Forged Outlook MUA", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY2", + "weight": 4.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THEBAT_MSGID", + "weight": 4.0, + "description": "Message pretends to be send from The Bat! but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY3", + "weight": 3.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY4", + "weight": 4.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_BOUNDARY", + "weight": 5.0, + "description": "Suspicious boundary in Content-Type header", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_POSTBOX_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Forged mail pretending to be from Postbox but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MOZILLA_MAIL_MSGID", + "weight": 4.0, + "description": "Message pretends to be send from Mozilla Mail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THUNDERBIRD_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Forged mail pretending to be from Mozilla Thunderbird but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MAILLIST", + "weight": 0.0, + "description": "Avoid false positives for FORGED_MUA_* in maillist", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_THUNDERBIRD_MSGID", + "weight": 4.0, + "description": "Forged mail pretending to be from Mozilla Thunderbird but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_MOZILLA_MAIL_MSGID_UNKNOWN", + "weight": 2.500000, + "description": "Message pretends to be send from Mozilla Mail but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FORGED_MUA_POSTBOX_MSGID", + "weight": 4.0, + "description": "Forged mail pretending to be from Postbox but has forged Message-ID", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "whitelist", + "rules": [ + { + "symbol": "WHITELIST_DKIM", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF_DKIM", + "weight": -3.0, + "description": "Mail comes from the whitelisted domain and has valid SPF and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DMARC", + "weight": 6.0, + "description": "Mail comes from the whitelisted domain and has failed DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_DMARC", + "weight": -7.0, + "description": "Mail comes from the whitelisted domain and has valid DMARC and DKIM policies", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF_DKIM", + "weight": 3.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy or a bad DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_DKIM", + "weight": 2.0, + "description": "Mail comes from the whitelisted domain and has non-valid DKIM signature", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "WHITELIST_SPF", + "weight": -1.0, + "description": "Mail comes from the whitelisted domain and has a valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BLACKLIST_SPF", + "weight": 1.0, + "description": "Mail comes from the whitelisted domain and has no valid SPF policy", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "blankspam", + "rules": [ + { + "symbol": "COMPLETELY_EMPTY", + "weight": 15.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SHORT_PART_BAD_HEADERS", + "weight": 7.0, + "description": "MISSING_ESSENTIAL_HEADERS & SINGLE_SHORT_PART", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MISSING_ESSENTIAL_HEADERS", + "weight": 7.0, + "description": "Common headers were entirely absent", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "content", + "rules": [ + { + "symbol": "PDF_TIMEOUT", + "weight": 0.0, + "description": "There is a PDF in the message that caused timeout in processing", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_LONG_TRAILER", + "weight": 0.200000, + "description": "There is an PDF with a long trailer in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_JAVASCRIPT", + "weight": 0.100000, + "description": "There is an PDF with JavaScript in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_MANY_OBJECTS", + "weight": 0.0, + "description": "There is a PDF with too many objects in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_ENCRYPTED", + "weight": 0.300000, + "description": "There is an encrypted PDF in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "PDF_SUSPICIOUS", + "weight": 4.500000, + "description": "There is an PDF with suspicious properties in the message", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "Message ID", + "rules": [ + { + "symbol": "MID_CONTAINS_TO", + "weight": 1.0, + "description": "Message-ID contains To address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_MISSING_BRACKETS", + "weight": 0.500000, + "description": "Message-ID is missing <>'s", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_MATCH_TO", + "weight": 1.0, + "description": "Message-ID RHS matches To domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_NOT_FQDN", + "weight": 0.500000, + "description": "Message-ID RHS is not a fully-qualified domain name", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_MATCH_FROM", + "weight": 0.0, + "description": "Message-ID RHS matches From domain", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_CONTAINS_FROM", + "weight": 1.0, + "description": "Message-ID contains From address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_BARE_IP", + "weight": 2.0, + "description": "Message-ID RHS is a bare IP address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_IP_LITERAL", + "weight": 0.500000, + "description": "Message-ID RHS is an IP-literal", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "MID_RHS_MATCH_FROMTLD", + "weight": 0.0, + "description": "Message-ID RHS matches From domain tld", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "headers,mime", + "rules": [ + { + "symbol": "CHECK_TO_CC", + "weight": 0.0, + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "scams", + "rules": [ + { + "symbol": "LEAKED_PASSWORD_SCAM_RE", + "weight": 0.0, + "description": "Contains BTC wallet address and malicious regexps", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "FREEMAIL_AFF", + "weight": 4.0, + "description": "Message exhibits strong characteristics of advance fee fraud (AFF a/k/a '419' spam) involving freemail addresses", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "INTRODUCTION", + "weight": 2.0, + "description": "Sender introduces themselves", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "SUSPICIOUS_MDN", + "weight": 2.0, + "description": "Message delivery notification should go to freemail or disposable e-mail, but message was not sent from a freemail address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "BITCOIN_ADDR", + "weight": 0.0, + "description": "Message has a valid bitcoin wallet address", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "LEAKED_PASSWORD_SCAM", + "weight": 7.0, + "description": "Contains BTC wallet address and scam patterns", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + }, + { + "group": "body", + "rules": [ + { + "symbol": "HAS_ATTACHMENT", + "weight": 0.0, + "description": "Message contains attachments", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + }, + { + "symbol": "R_PARTS_DIFFER", + "weight": 1.0, + "description": "Text and HTML parts differ", + "frequency": 0.0, + "frequency_stddev": 0.0, + "time": 0.0 + } + ] + } +] diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go index f37467b..9780f17 100644 --- a/pkg/analyzer/rspamd.go +++ b/pkg/analyzer/rspamd.go @@ -37,11 +37,13 @@ const ( ) // RspamdAnalyzer analyzes rspamd results from email headers -type RspamdAnalyzer struct{} +type RspamdAnalyzer struct { + symbols map[string]string +} -// NewRspamdAnalyzer creates a new rspamd analyzer -func NewRspamdAnalyzer() *RspamdAnalyzer { - return &RspamdAnalyzer{} +// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions +func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer { + return &RspamdAnalyzer{symbols: symbols} } // AnalyzeRspamd extracts and analyzes rspamd results from email headers @@ -83,6 +85,16 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult { result.Server = &server } + // Populate symbol descriptions from the lookup map + if a.symbols != nil { + for name, sym := range result.Symbols { + if desc, ok := a.symbols[name]; ok { + sym.Description = &desc + result.Symbols[name] = sym + } + } + } + // Derive IsSpam from score vs reject threshold. if result.Threshold > 0 { result.IsSpam = result.Score >= result.Threshold diff --git a/pkg/analyzer/rspamd_symbols.go b/pkg/analyzer/rspamd_symbols.go new file mode 100644 index 0000000..e50a452 --- /dev/null +++ b/pkg/analyzer/rspamd_symbols.go @@ -0,0 +1,105 @@ +// 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 ( + _ "embed" + "encoding/json" + "io" + "log" + "net/http" + "strings" + "time" +) + +//go:embed rspamd-symbols.json +var embeddedRspamdSymbols []byte + +// rspamdSymbolGroup represents a group of rspamd symbols from the API/embedded JSON. +type rspamdSymbolGroup struct { + Group string `json:"group"` + Rules []rspamdSymbolEntry `json:"rules"` +} + +// rspamdSymbolEntry represents a single rspamd symbol entry. +type rspamdSymbolEntry struct { + Symbol string `json:"symbol"` + Description string `json:"description"` + Weight float64 `json:"weight"` +} + +// parseRspamdSymbolsJSON parses the rspamd symbols JSON into a name->description map. +func parseRspamdSymbolsJSON(data []byte) map[string]string { + var groups []rspamdSymbolGroup + if err := json.Unmarshal(data, &groups); err != nil { + log.Printf("Failed to parse rspamd symbols JSON: %v", err) + return nil + } + + symbols := make(map[string]string, len(groups)*10) + for _, g := range groups { + for _, r := range g.Rules { + if r.Description != "" { + symbols[r.Symbol] = r.Description + } + } + } + return symbols +} + +// LoadRspamdSymbols loads rspamd symbol descriptions. +// If apiURL is non-empty, it fetches from the rspamd API first, falling back to the embedded list on error. +func LoadRspamdSymbols(apiURL string) map[string]string { + if apiURL != "" { + if symbols := fetchRspamdSymbols(apiURL); symbols != nil { + return symbols + } + log.Printf("Failed to fetch rspamd symbols from %s, using embedded list", apiURL) + } + return parseRspamdSymbolsJSON(embeddedRspamdSymbols) +} + +// fetchRspamdSymbols fetches symbol descriptions from the rspamd API. +func fetchRspamdSymbols(apiURL string) map[string]string { + url := strings.TrimRight(apiURL, "/") + "/symbols" + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + log.Printf("Error fetching rspamd symbols: %v", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Printf("rspamd API returned status %d", resp.StatusCode) + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Error reading rspamd symbols response: %v", err) + return nil + } + + return parseRspamdSymbolsJSON(body) +} diff --git a/pkg/analyzer/rspamd_test.go b/pkg/analyzer/rspamd_test.go index df37744..0eeca85 100644 --- a/pkg/analyzer/rspamd_test.go +++ b/pkg/analyzer/rspamd_test.go @@ -30,7 +30,7 @@ import ( ) func TestAnalyzeRspamdNoHeaders(t *testing.T) { - analyzer := NewRspamdAnalyzer() + analyzer := NewRspamdAnalyzer(nil) email := &EmailMessage{Header: make(mail.Header)} result := analyzer.AnalyzeRspamd(email) @@ -126,7 +126,7 @@ func TestParseSpamdResult(t *testing.T) { }, } - analyzer := NewRspamdAnalyzer() + analyzer := NewRspamdAnalyzer(nil) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -241,7 +241,7 @@ func TestAnalyzeRspamd(t *testing.T) { }, } - analyzer := NewRspamdAnalyzer() + analyzer := NewRspamdAnalyzer(nil) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -340,7 +340,7 @@ func TestCalculateRspamdScore(t *testing.T) { }, } - analyzer := NewRspamdAnalyzer() + analyzer := NewRspamdAnalyzer(nil) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -380,7 +380,7 @@ func TestAnalyzeRspamdRealEmail(t *testing.T) { t.Fatalf("Failed to parse email: %v", err) } - analyzer := NewRspamdAnalyzer() + analyzer := NewRspamdAnalyzer(nil) result := analyzer.AnalyzeRspamd(email) if result == nil { diff --git a/web/src/lib/components/RspamdCard.svelte b/web/src/lib/components/RspamdCard.svelte index 0db6378..4c2795b 100644 --- a/web/src/lib/components/RspamdCard.svelte +++ b/web/src/lib/components/RspamdCard.svelte @@ -75,7 +75,7 @@ Symbol Score - Parameters + Description @@ -87,7 +87,14 @@ ? "table-success" : ""} > - {symbolName} + + {symbolName} + {#if symbol.params} + + {symbol.params} + + {/if} + 0 @@ -99,7 +106,7 @@ {symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)} - {symbol.params ?? ""} + {symbol.description ?? ""} {/each} From a36824cf272b3b25efed43af9e8ac12142182148 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 26 Mar 2026 09:42:09 +0700 Subject: [PATCH 10/24] Fix DKIM headers retrieval Bug: https://github.com/happyDomain/happydeliver/issues/11 --- pkg/analyzer/dns.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 10babb0..29d8211 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -105,7 +105,7 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.Header results.SpfRecords = d.checkSPFRecords(spfDomain) // Check DKIM records by parsing DKIM-Signature headers directly - for _, sig := range parseDKIMSignatures(email.Header["DKIM-Signature"]) { + for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) { dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector) if dkimRecord != nil { if results.DkimRecords == nil { From b158336451555e0ca567950634dff3e064d2f21c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 26 Mar 2026 09:51:29 +0700 Subject: [PATCH 11/24] Filter Received-SPF header by receiver hostname Ensures parseLegacySPF only trusts Received-SPF headers where the receiver= field matches the configured receiverHostname, preventing incorrect SPF results from unrelated receivers. --- pkg/analyzer/authentication_spf.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go index 479c325..fc41e3c 100644 --- a/pkg/analyzer/authentication_spf.go +++ b/pkg/analyzer/authentication_spf.go @@ -63,6 +63,16 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe return nil } + // Verify receiver matches our hostname + if a.receiverHostname != "" { + receiverRe := regexp.MustCompile(`receiver=([^\s;]+)`) + if matches := receiverRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { + if matches[1] != a.receiverHostname { + return nil + } + } + } + result := &api.AuthResult{} // Extract result (first word) From dee848d8875b71bc9e6c8e03866b292badfc33be Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 26 Mar 2026 10:12:31 +0700 Subject: [PATCH 12/24] Rebalance authentication score: SPF/DKIM/DMARC as core, penalties for optional results IPRev and X-Aligned-From now only penalize on failure instead of contributing positively. Core authentication (SPF/DKIM/DMARC) rebalanced to 30 points each, BIMI stays at 10, totaling 100 base points. Bug: https://github.com/happyDomain/happydeliver/issues/11 --- pkg/analyzer/authentication.go | 35 +++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 2051a56..2beeb1f 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -152,27 +152,32 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe score := 0 - // IPRev (15 points) - score += 15 * a.calculateIPRevScore(results) / 100 + // Core authentication (90 points total) + // SPF (30 points) + score += 30 * a.calculateSPFScore(results) / 100 - // SPF (25 points) - score += 25 * a.calculateSPFScore(results) / 100 + // DKIM (30 points) + score += 30 * a.calculateDKIMScore(results) / 100 - // DKIM (23 points) - score += 23 * a.calculateDKIMScore(results) / 100 - - // X-Google-DKIM (optional) - penalty if failed - score += 12 * a.calculateXGoogleDKIMScore(results) / 100 - - // X-Aligned-From - score += 2 * a.calculateXAlignedFromScore(results) / 100 - - // DMARC (25 points) - score += 25 * a.calculateDMARCScore(results) / 100 + // DMARC (30 points) + score += 30 * a.calculateDMARCScore(results) / 100 // BIMI (10 points) score += 10 * a.calculateBIMIScore(results) / 100 + // Penalty-only: IPRev (up to -7 points on failure) + if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 { + score += 7 * (iprevScore - 100) / 100 + } + + // Penalty-only: X-Google-DKIM (up to -12 points on failure) + score += 12 * a.calculateXGoogleDKIMScore(results) / 100 + + // Penalty-only: X-Aligned-From (up to -5 points on failure) + if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 { + score += 5 * (xAlignedScore - 100) / 100 + } + // Ensure score doesn't exceed 100 if score > 100 { score = 100 From dfa38e8a26834533d69a0daa2efc3e723378b6cc Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 26 Mar 2026 10:22:50 +0700 Subject: [PATCH 13/24] Fix RBL score: return A+ when not listed on any blocklist Move the ListedCount check before scoringListCount calculation so we return early with a perfect score when the IP/domain is not listed, regardless of how many informational-only lists exist. --- pkg/analyzer/rbl.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 08d3b8f..64db1f7 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -305,11 +305,11 @@ func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) { return 100, "" } - scoringListCount := len(r.Lists) - len(r.informationalSet) - if scoringListCount <= 0 { + if results.ListedCount <= 0 { return 100, "A+" } + scoringListCount := len(r.Lists) - len(r.informationalSet) percentage := 100 - results.RelevantListedCount*100/scoringListCount return percentage, ScoreToGrade(percentage) } From 16b7dcb057ee91d51e39436b4f8abcc94695101a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 26 Mar 2026 10:31:09 +0700 Subject: [PATCH 14/24] Incorporate DNSWL (whitelist) grade into blacklist scoring CalculateScore now accepts a forWhitelist flag to handle whitelist scoring logic separately. The final blacklist grade combines both RBL and DNSWL results using MinGrade for a more accurate reputation assessment. --- pkg/analyzer/analyzer.go | 2 +- pkg/analyzer/rbl.go | 15 +++++++++++++-- pkg/analyzer/report.go | 6 ++++-- pkg/analyzer/scoring.go | 2 ++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 54d9e42..f21d1f8 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -138,7 +138,7 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.Bl IPsChecked: []string{ip}, ListedCount: listedCount, } - score, grade := a.analyzer.generator.rblChecker.CalculateScore(results) + score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false) // Check the IP against all configured DNSWLs (informational only) whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip) diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 64db1f7..47e74e0 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -300,7 +300,19 @@ func (r *DNSListChecker) reverseIP(ipStr string) string { // CalculateScore calculates the list contribution to deliverability. // Informational lists are not counted in the score. -func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) { +func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bool) (int, string) { + scoringListCount := len(r.Lists) - len(r.informationalSet) + + if forWhitelist { + if results.ListedCount >= scoringListCount { + return 100, "A++" + } else if results.ListedCount > 0 { + return 100, "A+" + } else { + return 95, "A" + } + } + if results == nil || len(results.IPsChecked) == 0 { return 100, "" } @@ -309,7 +321,6 @@ func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) { return 100, "A+" } - scoringListCount := len(r.Lists) - len(r.informationalSet) percentage := 100 - results.RelevantListedCount*100/scoringListCount return percentage, ScoreToGrade(percentage) } diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 6dcf588..7332307 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -141,8 +141,10 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu blacklistScore := 0 var blacklistGrade string + var whitelistGrade string if results.RBL != nil { - blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL) + blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false) + _, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true) } saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) @@ -173,7 +175,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu AuthenticationScore: authScore, AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade), BlacklistScore: blacklistScore, - BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade), + BlacklistGrade: api.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)), ContentScore: contentScore, ContentGrade: api.ScoreSummaryContentGrade(contentGrade), HeaderScore: headerScore, diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index 798590f..5568c8e 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -73,6 +73,8 @@ func ScoreToReportGrade(score int) api.ReportGrade { // gradeRank returns a numeric rank for a grade (lower = worse) func gradeRank(grade string) int { switch grade { + case "A++": + return 7 case "A+": return 6 case "A": From e540377bd94b9652d70f37be1ac4580af7638b66 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 27 Mar 2026 17:57:08 +0700 Subject: [PATCH 15/24] Don't penalize non iprev result nor aligned-from if non-existant Bug: https://github.com/happyDomain/happydeliver/issues/11 --- pkg/analyzer/authentication_iprev.go | 2 +- pkg/analyzer/authentication_x_aligned_from.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go index 6538cbb..e799094 100644 --- a/pkg/analyzer/authentication_iprev.go +++ b/pkg/analyzer/authentication_iprev.go @@ -69,5 +69,5 @@ func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.Authentication } } - return 0 + return 100 } diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go index 36da2b0..eb0cf98 100644 --- a/pkg/analyzer/authentication_x_aligned_from.go +++ b/pkg/analyzer/authentication_x_aligned_from.go @@ -61,5 +61,5 @@ func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.Authent } } - return 0 + return 100 } From 7422f6ed0a5c1f4306d6656189683fbb03828d13 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 9 Apr 2026 17:46:08 +0700 Subject: [PATCH 16/24] Add paginated test history listing with disable option Add GET /tests endpoint returning lightweight test summaries (grade, score, domain, date) with pagination, using database-level JSON extraction to avoid loading full report blobs. The feature can be disabled with --disable-test-list flag. Frontend includes a new /tests/ page with table view and a conditional "History" navbar link. Fixes: https://github.com/happyDomain/happydeliver/issues/12 --- api/openapi.yaml | 93 ++++++++++ internal/api/handlers.go | 75 ++++++++ internal/config/cli.go | 1 + internal/config/config.go | 1 + internal/storage/storage.go | 51 ++++++ web/routes.go | 5 + web/src/lib/components/HistoryTable.svelte | 72 ++++++++ web/src/lib/components/index.ts | 1 + web/src/lib/stores/config.ts | 1 + web/src/routes/+layout.svelte | 12 +- web/src/routes/+page.svelte | 48 +++++- web/src/routes/history/+page.svelte | 189 +++++++++++++++++++++ 12 files changed, 546 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/components/HistoryTable.svelte create mode 100644 web/src/routes/history/+page.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index 225e26c..ee56cff 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -76,6 +76,49 @@ paths: schema: $ref: '#/components/schemas/Error' + /tests: + get: + tags: + - tests + summary: List all tests + description: Returns a paginated list of test summaries with scores and grades. Can be disabled via server configuration. + operationId: listTests + parameters: + - name: offset + in: query + schema: + type: integer + minimum: 0 + default: 0 + description: Number of items to skip + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + description: Maximum number of items to return + responses: + '200': + description: List of test summaries + content: + application/json: + schema: + $ref: '#/components/schemas/TestListResponse' + '403': + description: Test listing is disabled + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /report/{id}: get: tags: @@ -1365,3 +1408,53 @@ components: 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/internal/api/handlers.go b/internal/api/handlers.go index 470136e..e524b40 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -381,3 +381,78 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) { c.JSON(http.StatusOK, response) } + +// ListTests returns a paginated list of test summaries +// (GET /tests) +func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { + if h.config.DisableTestList { + c.JSON(http.StatusForbidden, Error{ + Error: "feature_disabled", + Message: "Test listing is disabled on this instance", + }) + return + } + + offset := 0 + limit := 20 + if params.Offset != nil { + offset = *params.Offset + } + if params.Limit != nil { + limit = *params.Limit + if limit > 100 { + limit = 100 + } + } + + summaries, total, err := h.storage.ListReportSummaries(offset, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to list tests", + Details: stringPtr(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{ + Tests: tests, + Total: int(total), + Offset: offset, + Limit: limit, + }) +} diff --git a/internal/config/cli.go b/internal/config/cli.go index 77108ca..fcc914f 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -44,6 +44,7 @@ func declareFlags(o *Config) { flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI") + flag.BoolVar(&o.DisableTestList, "disable-test-list", o.DisableTestList, "Disable the public test listing endpoint") // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations } diff --git a/internal/config/config.go b/internal/config/config.go index 9d803d0..b264994 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,6 +50,7 @@ type Config struct { RateLimit uint // API rate limit (requests per second per IP) SurveyURL url.URL // URL for user feedback survey CustomLogoURL string // URL for custom logo image in the web UI + DisableTestList bool // Disable the public test listing endpoint } // DatabaseConfig contains database connection settings diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 39b2eb6..1077e74 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -45,11 +45,21 @@ 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) // 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 @@ -139,6 +149,47 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { return result.RowsAffected, nil } +// ListReportSummaries returns a paginated list of lightweight report summaries +func (s *DBStorage) ListReportSummaries(offset, limit int) ([]ReportSummary, 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 + } + + var selectExpr string + switch s.db.Dialector.Name() { + case "postgres": + selectExpr = `test_id, ` + + `(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` + + `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 + 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` + } + + var summaries []ReportSummary + err := s.db.Model(&Report{}). + Select(selectExpr). + Order("created_at DESC"). + Offset(offset). + Limit(limit). + Scan(&summaries).Error + if err != nil { + return nil, 0, fmt.Errorf("failed to list report summaries: %w", err) + } + + return summaries, total, nil +} + // Close closes the database connection func (s *DBStorage) Close() error { sqlDB, err := s.db.DB() diff --git a/web/routes.go b/web/routes.go index 876954c..056115d 100644 --- a/web/routes.go +++ b/web/routes.go @@ -70,6 +70,10 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) { appConfig["custom_logo_url"] = cfg.CustomLogoURL } + if !cfg.DisableTestList { + appConfig["test_list_enabled"] = true + } + if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil { log.Println("Unable to generate JSON config to inject in web application") } else { @@ -95,6 +99,7 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) { router.GET("/domain/:domain", serveOrReverse("/", cfg)) router.GET("/test/", serveOrReverse("/", cfg)) router.GET("/test/:testid", serveOrReverse("/", cfg)) + router.GET("/history/", serveOrReverse("/", cfg)) router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg)) router.GET("/img/*path", serveOrReverse("", cfg)) diff --git a/web/src/lib/components/HistoryTable.svelte b/web/src/lib/components/HistoryTable.svelte new file mode 100644 index 0000000..737d025 --- /dev/null +++ b/web/src/lib/components/HistoryTable.svelte @@ -0,0 +1,72 @@ + + +
+ + + + + + + + + + + + {#each tests as test} + goto(`/test/${test.test_id}`)}> + + + + + + + {/each} + +
GradeScoreDomainDate
+ + + {test.score}% + + {#if test.from_domain} + {test.from_domain} + {:else} + - + {/if} + + {formatDate(test.created_at)} + + +
+
+ + 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 17/24] 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 18/24] 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