diff --git a/Dockerfile b/Dockerfile index f6dc16a..9626813 100644 --- a/Dockerfile +++ b/Dockerfile @@ -170,7 +170,12 @@ RUN chmod +x /entrypoint.sh EXPOSE 25 8080 # Default configuration -ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net +ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \ + HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \ + HAPPYDELIVER_DOMAIN=happydeliver.local \ + HAPPYDELIVER_ADDRESS_PREFIX=test- \ + HAPPYDELIVER_DNS_TIMEOUT=5s \ + HAPPYDELIVER_HTTP_TIMEOUT=10s # Volume for persistent data VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"] diff --git a/api/openapi.yaml b/api/openapi.yaml index c0c3c70..e989261 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -982,6 +982,9 @@ components: name: "BAYES_HAM" score: -1.9 params: "0.02" + report: + type: string + description: Full rspamd report (raw X-Spamd-Result header) RspamdSymbol: type: object @@ -1343,7 +1346,7 @@ components: type: object required: - ip - - checks + - blacklists - listed_count - score - grade @@ -1352,7 +1355,7 @@ components: type: string description: The IP address that was checked example: "192.0.2.1" - checks: + blacklists: type: array items: $ref: '#/components/schemas/BlacklistCheck' @@ -1372,3 +1375,8 @@ components: 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) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 80c8f9a..470136e 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -41,7 +41,7 @@ import ( 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, listedCount int, score int, grade string, err error) + CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error) } // APIHandler implements the ServerInterface for handling API requests @@ -359,7 +359,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) { } // Perform blacklist check using analyzer - checks, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip) + checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip) if err != nil { c.JSON(http.StatusBadRequest, Error{ Error: "invalid_ip", @@ -372,7 +372,8 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) { // Build response response := BlacklistCheckResponse{ Ip: request.Ip, - Checks: checks, + Blacklists: checks, + Whitelists: &whitelists, ListedCount: listedCount, Score: score, Grade: BlacklistCheckResponseGrade(grade), diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 83eafe6..a16829b 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -121,12 +121,12 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) return dnsResults, score, grade } -// CheckBlacklistIP checks a single IP address against DNS blacklists -func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int, string, error) { +// 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) { // Check the IP against all configured RBLs checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip) if err != nil { - return nil, 0, 0, "", err + return nil, nil, 0, 0, "", err } // Calculate score using the existing function @@ -138,5 +138,11 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int } score, grade := a.analyzer.generator.rblChecker.CalculateScore(results) - return checks, listedCount, score, grade, nil + // Check the IP against all configured DNSWLs (informational only) + whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip) + if err != nil { + whitelists = nil + } + + return checks, whitelists, listedCount, score, grade, nil } diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 44c6e99..08d3b8f 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -27,6 +27,7 @@ import ( "net" "regexp" "strings" + "sync" "time" "git.happydns.org/happyDeliver/internal/api" @@ -53,8 +54,6 @@ var DefaultRBLs = []string{ "dnsbl-1.uceprotect.net", // UCEPROTECT Level 1 "dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational) "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational) - "spam.spamrats.com", // SpamRats SPAM - "dyna.spamrats.com", // SpamRats dynamic IPs "psbl.surriel.com", // PSBL "dnsbl.dronebl.org", // DroneBL "bl.mailspike.net", // Mailspike BL @@ -157,18 +156,26 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults { return results } -// CheckIP checks a single IP address against all configured lists +// CheckIP checks a single IP address against all configured lists in parallel func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) { if !r.isPublicIP(ip) { return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip) } - var checks []api.BlacklistCheck - listedCount := 0 + checks := make([]api.BlacklistCheck, len(r.Lists)) + var wg sync.WaitGroup - for _, list := range r.Lists { - check := r.checkIP(ip, list) - checks = append(checks, check) + for i, list := range r.Lists { + wg.Add(1) + go func(i int, list string) { + defer wg.Done() + checks[i] = r.checkIP(ip, list) + }(i, list) + } + wg.Wait() + + listedCount := 0 + for _, check := range checks { if check.Listed { listedCount++ } diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index 86f3c95..1dd1262 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) { func TestGetBlacklistScore(t *testing.T) { tests := []struct { name string - results *RBLResults + results *DNSListResults expectedScore int }{ { @@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "No IPs checked", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{}, }, expectedScore: 100, }, { name: "Not listed on any RBL", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, @@ -290,7 +290,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 1 RBL", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, }, @@ -298,7 +298,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 2 RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, }, @@ -306,7 +306,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 3 RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 3, }, @@ -314,7 +314,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 4+ RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 4, }, @@ -335,7 +335,7 @@ func TestGetBlacklistScore(t *testing.T) { } func TestGetUniqueListedIPs(t *testing.T) { - results := &RBLResults{ + results := &DNSListResults{ Checks: map[string][]api.BlacklistCheck{ "198.51.100.1": { {Rbl: "zen.spamhaus.org", Listed: true}, @@ -363,7 +363,7 @@ func TestGetUniqueListedIPs(t *testing.T) { } func TestGetRBLsForIP(t *testing.T) { - results := &RBLResults{ + results := &DNSListResults{ Checks: map[string][]api.BlacklistCheck{ "198.51.100.1": { {Rbl: "zen.spamhaus.org", Listed: true}, diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go index d394c62..f3f548b 100644 --- a/pkg/analyzer/rspamd.go +++ b/pkg/analyzer/rspamd.go @@ -58,6 +58,8 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult { // Parse X-Spamd-Result header (primary source for score, threshold, and symbols) // Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..." if spamdResult, ok := headers["X-Spamd-Result"]; ok { + report := strings.ReplaceAll(spamdResult, "; ", ";\n") + result.Report = &report a.parseSpamdResult(spamdResult, result) } @@ -111,8 +113,9 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul } // Parse symbols: SYMBOL(score)[params] - // Each symbol entry is separated by ";" - symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[([^\]]*)\])?`) + // Each symbol entry is separated by ";", so within each part we use a + // greedy match to capture params that may contain nested brackets. + symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`) for _, part := range strings.Split(header, ";") { part = strings.TrimSpace(part) matches := symbolRe.FindStringSubmatch(part) diff --git a/pkg/analyzer/rspamd_test.go b/pkg/analyzer/rspamd_test.go new file mode 100644 index 0000000..de98fe8 --- /dev/null +++ b/pkg/analyzer/rspamd_test.go @@ -0,0 +1,414 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "bytes" + "net/mail" + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestAnalyzeRspamdNoHeaders(t *testing.T) { + analyzer := NewRspamdAnalyzer() + email := &EmailMessage{Header: make(mail.Header)} + + result := analyzer.AnalyzeRspamd(email) + + if result != nil { + t.Errorf("Expected nil for email without rspamd headers, got %+v", result) + } +} + +func TestParseSpamdResult(t *testing.T) { + tests := []struct { + name string + header string + expectedScore float32 + expectedThreshold float32 + expectedIsSpam bool + expectedSymbols map[string]float32 + expectedSymParams map[string]string + }{ + { + name: "Clean email negative score", + header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]", + expectedScore: -3.91, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "DATE_IN_PAST": 0.10, + "ALL_TRUSTED": -1.00, + }, + expectedSymParams: map[string]string{ + "ALL_TRUSTED": "trusted", + }, + }, + { + name: "Spam email True flag", + header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)", + expectedScore: 16.50, + expectedThreshold: 15.00, + expectedIsSpam: true, + expectedSymbols: map[string]float32{ + "BAYES_99": 5.00, + "SPOOFED_SENDER": 3.50, + }, + expectedSymParams: map[string]string{ + "BAYES_99": "1.00", + }, + }, + { + name: "Zero threshold uses default", + header: "default: False [1.00 / 0.00]", + expectedScore: 1.00, + expectedThreshold: rspamdDefaultAddHeaderThreshold, + expectedIsSpam: false, + expectedSymbols: map[string]float32{}, + }, + { + name: "Symbol without params", + header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)", + expectedScore: 2.00, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "MISSING_DATE": 1.00, + }, + }, + { + name: "Case-insensitive true flag", + header: "default: true [8.00 / 6.00]", + expectedScore: 8.00, + expectedThreshold: 6.00, + expectedIsSpam: true, + expectedSymbols: map[string]float32{}, + }, + { + name: "Zero threshold with symbols containing nested brackets in params", + header: "default: False [0.90 / 0.00];\n" + + "\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" + + "\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" + + "\tMIME_TRACE(0.00)[0:+,1:+,2:~]", + expectedScore: 0.90, + expectedThreshold: rspamdDefaultAddHeaderThreshold, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "ARC_REJECT": 1.00, + "MIME_GOOD": -0.10, + "MIME_TRACE": 0.00, + }, + expectedSymParams: map[string]string{ + "ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}", + "MIME_GOOD": "multipart/alternative,text/plain", + "MIME_TRACE": "0:+,1:+,2:~", + }, + }, + } + + analyzer := NewRspamdAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &api.RspamdResult{ + Symbols: make(map[string]api.RspamdSymbol), + } + analyzer.parseSpamdResult(tt.header, result) + + if result.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore) + } + if result.Threshold != tt.expectedThreshold { + t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold) + } + if result.IsSpam != tt.expectedIsSpam { + t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam) + } + for symName, expectedScore := range tt.expectedSymbols { + sym, ok := result.Symbols[symName] + if !ok { + t.Errorf("Symbol %s not found", symName) + continue + } + if sym.Score != expectedScore { + t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore) + } + } + for symName, expectedParam := range tt.expectedSymParams { + sym, ok := result.Symbols[symName] + if !ok { + t.Errorf("Symbol %s not found for params check", symName) + continue + } + if sym.Params == nil { + t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam) + } else if *sym.Params != expectedParam { + t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam) + } + } + }) + } +} + +func TestAnalyzeRspamd(t *testing.T) { + tests := []struct { + name string + headers map[string]string + expectedScore float32 + expectedThreshold float32 + expectedIsSpam bool + expectedServer *string + expectedSymCount int + }{ + { + name: "Full headers clean email", + headers: map[string]string{ + "X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]", + "X-Rspamd-Score": "-3.91", + "X-Rspamd-Server": "mail.example.com", + }, + expectedScore: -3.91, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedServer: func() *string { s := "mail.example.com"; return &s }(), + expectedSymCount: 1, + }, + { + name: "X-Rspamd-Score overrides spamd result score", + headers: map[string]string{ + "X-Spamd-Result": "default: False [2.00 / 15.00]", + "X-Rspamd-Score": "3.50", + }, + expectedScore: 3.50, + expectedThreshold: 15.00, + expectedIsSpam: false, + }, + { + name: "Spam email above threshold", + headers: map[string]string{ + "X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)", + "X-Rspamd-Score": "16.00", + }, + expectedScore: 16.00, + expectedThreshold: 15.00, + expectedIsSpam: true, + expectedSymCount: 1, + }, + { + name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold", + headers: map[string]string{ + "X-Rspamd-Score": "2.00", + }, + expectedScore: 2.00, + expectedIsSpam: false, + }, + { + name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold", + headers: map[string]string{ + "X-Rspamd-Score": "7.00", + }, + expectedScore: 7.00, + expectedIsSpam: true, + }, + { + name: "Server header is trimmed", + headers: map[string]string{ + "X-Rspamd-Score": "1.00", + "X-Rspamd-Server": " rspamd-01 ", + }, + expectedScore: 1.00, + expectedServer: func() *string { s := "rspamd-01"; return &s }(), + }, + } + + analyzer := NewRspamdAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{Header: make(mail.Header)} + for k, v := range tt.headers { + email.Header[k] = []string{v} + } + + result := analyzer.AnalyzeRspamd(email) + + if result == nil { + t.Fatal("Expected non-nil result") + } + if result.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore) + } + if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold { + t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold) + } + if result.IsSpam != tt.expectedIsSpam { + t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam) + } + if tt.expectedServer != nil { + if result.Server == nil { + t.Errorf("Server = nil, want %q", *tt.expectedServer) + } else if *result.Server != *tt.expectedServer { + t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer) + } + } + if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount { + t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount) + } + }) + } +} + +func TestCalculateRspamdScore(t *testing.T) { + tests := []struct { + name string + result *api.RspamdResult + expectedScore int + expectedGrade string + }{ + { + name: "Nil result (rspamd not installed)", + result: nil, + expectedScore: 100, + expectedGrade: "", + }, + { + name: "Score well below threshold", + result: &api.RspamdResult{ + Score: -3.91, + Threshold: 15.00, + }, + expectedScore: 100, + expectedGrade: "A+", + }, + { + name: "Score at zero", + result: &api.RspamdResult{ + Score: 0, + Threshold: 15.00, + }, + // 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A" + expectedScore: 100, + expectedGrade: "A", + }, + { + name: "Score at threshold (half of 2*threshold)", + result: &api.RspamdResult{ + Score: 15.00, + Threshold: 15.00, + }, + // 100 - round(15*100/(2*15)) = 100 - 50 = 50 + expectedScore: 50, + }, + { + name: "Score above 2*threshold", + result: &api.RspamdResult{ + Score: 31.00, + Threshold: 15.00, + }, + expectedScore: 0, + expectedGrade: "F", + }, + { + name: "Score exactly at 2*threshold", + result: &api.RspamdResult{ + Score: 30.00, + Threshold: 15.00, + }, + // 100 - round(30*100/30) = 100 - 100 = 0 + expectedScore: 0, + expectedGrade: "F", + }, + } + + analyzer := NewRspamdAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score, grade := analyzer.CalculateRspamdScore(tt.result) + + if score != tt.expectedScore { + t.Errorf("Score = %d, want %d", score, tt.expectedScore) + } + if tt.expectedGrade != "" && grade != tt.expectedGrade { + t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade) + } + }) + } +} + +const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00]; + BAYES_HAM(-3.00)[99%]; + RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from]; + R_DKIM_ALLOW(-0.20)[example.com:s=dkim]; + FROM_HAS_DN(0.00)[]; + MIME_GOOD(-0.10)[text/plain]; +X-Rspamd-Score: -3.91 +X-Rspamd-Server: rspamd-01.example.com +Date: Mon, 09 Mar 2026 10:00:00 +0000 +From: sender@example.com +To: test@happydomain.org +Subject: Test email +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +Hello world` + +func TestAnalyzeRspamdRealEmail(t *testing.T) { + email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + analyzer := NewRspamdAnalyzer() + result := analyzer.AnalyzeRspamd(email) + + if result == nil { + t.Fatal("Expected non-nil result") + } + if result.IsSpam { + t.Error("Expected IsSpam=false") + } + if result.Score != -3.91 { + t.Errorf("Score = %v, want -3.91", result.Score) + } + if result.Threshold != 15.00 { + t.Errorf("Threshold = %v, want 15.00", result.Threshold) + } + if result.Server == nil || *result.Server != "rspamd-01.example.com" { + t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server) + } + + expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"} + for _, sym := range expectedSymbols { + if _, ok := result.Symbols[sym]; !ok { + t.Errorf("Symbol %s not found", sym) + } + } + + score, _ := analyzer.CalculateRspamdScore(result) + if score != 100 { + t.Errorf("CalculateRspamdScore = %d, want 100", score) + } +} + diff --git a/web/package-lock.json b/web/package-lock.json index 8c49afe..835218b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1255,6 +1255,7 @@ "integrity": "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1298,6 +1299,7 @@ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", @@ -1376,17 +1378,11 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -1432,6 +1428,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1777,6 +1774,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1844,6 +1842,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2267,9 +2275,9 @@ "license": "MIT" }, "node_modules/devalue": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", - "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", "dev": true, "license": "MIT" }, @@ -2354,6 +2362,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3359,6 +3368,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3398,6 +3408,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3531,6 +3542,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3807,22 +3819,22 @@ } }, "node_modules/svelte": { - "version": "5.53.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.5.tgz", - "integrity": "sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow==", + "version": "5.49.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz", + "integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", - "aria-query": "5.3.1", + "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.3", + "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", @@ -3888,16 +3900,6 @@ } } }, - "node_modules/svelte/node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4004,6 +4006,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4080,6 +4083,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4338,6 +4342,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte index 7f9b7f2..bb80acb 100644 --- a/web/src/lib/components/BlacklistCard.svelte +++ b/web/src/lib/components/BlacklistCard.svelte @@ -1,23 +1,21 @@
-

+

Blacklist Checks @@ -35,11 +33,7 @@

- {#if receivedChain} - - {/if} - -
+
{#each Object.entries(blacklists) as [ip, checks]}
diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte index 8dc57b0..a4fda45 100644 --- a/web/src/lib/components/EmailPathCard.svelte +++ b/web/src/lib/components/EmailPathCard.svelte @@ -1,5 +1,6 @@ {#if receivedChain && receivedChain.length > 0} -
-
Email Path (Received Chain)
-
+
+
+

+ + Email Path +

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

{#if hop.with} diff --git a/web/src/lib/components/RspamdCard.svelte b/web/src/lib/components/RspamdCard.svelte index 2468f90..0db6378 100644 --- a/web/src/lib/components/RspamdCard.svelte +++ b/web/src/lib/components/RspamdCard.svelte @@ -17,8 +17,7 @@ const effectiveAction = $derived.by(() => { const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15; - if (rspamd.score >= rejectThreshold) - return { label: "Reject", cls: "bg-danger" }; + if (rspamd.score >= rejectThreshold) return { label: "Reject", cls: "bg-danger" }; if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD) return { label: "Add header", cls: "bg-warning text-dark" }; if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD) @@ -31,7 +30,7 @@

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

{/if} + + {#if rspamd.report} +
+ Raw Report +
{rspamd.report}
+
+ {/if}