Compare commits
3 commits
master
...
renovate/g
| Author | SHA1 | Date | |
|---|---|---|---|
| dbe7739fb2 | |||
| c7dc3577e4 | |||
| ff013fe694 |
16 changed files with 64 additions and 560 deletions
|
|
@ -170,12 +170,7 @@ RUN chmod +x /entrypoint.sh
|
||||||
EXPOSE 25 8080
|
EXPOSE 25 8080
|
||||||
|
|
||||||
# Default configuration
|
# Default configuration
|
||||||
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
|
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
|
||||||
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 for persistent data
|
||||||
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
||||||
|
|
|
||||||
|
|
@ -982,9 +982,6 @@ components:
|
||||||
name: "BAYES_HAM"
|
name: "BAYES_HAM"
|
||||||
score: -1.9
|
score: -1.9
|
||||||
params: "0.02"
|
params: "0.02"
|
||||||
report:
|
|
||||||
type: string
|
|
||||||
description: Full rspamd report (raw X-Spamd-Result header)
|
|
||||||
|
|
||||||
RspamdSymbol:
|
RspamdSymbol:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -1346,7 +1343,7 @@ components:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- ip
|
- ip
|
||||||
- blacklists
|
- checks
|
||||||
- listed_count
|
- listed_count
|
||||||
- score
|
- score
|
||||||
- grade
|
- grade
|
||||||
|
|
@ -1355,7 +1352,7 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: The IP address that was checked
|
description: The IP address that was checked
|
||||||
example: "192.0.2.1"
|
example: "192.0.2.1"
|
||||||
blacklists:
|
checks:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/BlacklistCheck'
|
$ref: '#/components/schemas/BlacklistCheck'
|
||||||
|
|
@ -1375,8 +1372,3 @@ components:
|
||||||
enum: [A+, A, B, C, D, E, F]
|
enum: [A+, A, B, C, D, E, F]
|
||||||
description: Letter grade representation of the score
|
description: Letter grade representation of the score
|
||||||
example: "A+"
|
example: "A+"
|
||||||
whitelists:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/BlacklistCheck'
|
|
||||||
description: List of DNS whitelist check results (informational only)
|
|
||||||
|
|
|
||||||
5
go.mod
5
go.mod
|
|
@ -5,10 +5,9 @@ go 1.24.6
|
||||||
require (
|
require (
|
||||||
github.com/JGLTechnologies/gin-rate-limit v1.5.6
|
github.com/JGLTechnologies/gin-rate-limit v1.5.6
|
||||||
github.com/emersion/go-smtp v0.24.0
|
github.com/emersion/go-smtp v0.24.0
|
||||||
github.com/getkin/kin-openapi v0.133.0
|
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/oapi-codegen/runtime v1.1.2
|
github.com/oapi-codegen/runtime v1.2.0
|
||||||
golang.org/x/net v0.50.0
|
golang.org/x/net v0.50.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
|
|
@ -16,7 +15,6 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
|
@ -26,6 +24,7 @@ require (
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // 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/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||||
|
|
|
||||||
10
go.sum
10
go.sum
|
|
@ -1,9 +1,5 @@
|
||||||
github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI=
|
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/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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
|
@ -102,7 +98,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
|
@ -132,8 +127,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
||||||
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
|
github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4=
|
||||||
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0=
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||||
|
|
@ -170,7 +165,6 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g
|
||||||
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
|
github.com/speakeasy-api/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 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ import (
|
||||||
type EmailAnalyzer interface {
|
type EmailAnalyzer interface {
|
||||||
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
|
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
|
||||||
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
|
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
|
||||||
CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error)
|
CheckBlacklistIP(ip string) (checks []BlacklistCheck, listedCount int, score int, grade string, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIHandler implements the ServerInterface for handling API requests
|
// APIHandler implements the ServerInterface for handling API requests
|
||||||
|
|
@ -359,7 +359,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform blacklist check using analyzer
|
// Perform blacklist check using analyzer
|
||||||
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
checks, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, Error{
|
c.JSON(http.StatusBadRequest, Error{
|
||||||
Error: "invalid_ip",
|
Error: "invalid_ip",
|
||||||
|
|
@ -372,8 +372,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
||||||
// Build response
|
// Build response
|
||||||
response := BlacklistCheckResponse{
|
response := BlacklistCheckResponse{
|
||||||
Ip: request.Ip,
|
Ip: request.Ip,
|
||||||
Blacklists: checks,
|
Checks: checks,
|
||||||
Whitelists: &whitelists,
|
|
||||||
ListedCount: listedCount,
|
ListedCount: listedCount,
|
||||||
Score: score,
|
Score: score,
|
||||||
Grade: BlacklistCheckResponseGrade(grade),
|
Grade: BlacklistCheckResponseGrade(grade),
|
||||||
|
|
|
||||||
|
|
@ -121,12 +121,12 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string)
|
||||||
return dnsResults, score, grade
|
return dnsResults, score, grade
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
|
// CheckBlacklistIP checks a single IP address against DNS blacklists
|
||||||
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) {
|
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int, string, error) {
|
||||||
// Check the IP against all configured RBLs
|
// Check the IP against all configured RBLs
|
||||||
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, 0, 0, "", err
|
return nil, 0, 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate score using the existing function
|
// Calculate score using the existing function
|
||||||
|
|
@ -138,11 +138,5 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.Bl
|
||||||
}
|
}
|
||||||
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results)
|
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results)
|
||||||
|
|
||||||
// Check the IP against all configured DNSWLs (informational only)
|
return checks, listedCount, score, grade, nil
|
||||||
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)
|
|
||||||
if err != nil {
|
|
||||||
whitelists = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return checks, whitelists, listedCount, score, grade, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
|
@ -54,6 +53,8 @@ var DefaultRBLs = []string{
|
||||||
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
||||||
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
|
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
|
||||||
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
|
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
|
||||||
|
"spam.spamrats.com", // SpamRats SPAM
|
||||||
|
"dyna.spamrats.com", // SpamRats dynamic IPs
|
||||||
"psbl.surriel.com", // PSBL
|
"psbl.surriel.com", // PSBL
|
||||||
"dnsbl.dronebl.org", // DroneBL
|
"dnsbl.dronebl.org", // DroneBL
|
||||||
"bl.mailspike.net", // Mailspike BL
|
"bl.mailspike.net", // Mailspike BL
|
||||||
|
|
@ -156,26 +157,18 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckIP checks a single IP address against all configured lists in parallel
|
// CheckIP checks a single IP address against all configured lists
|
||||||
func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
||||||
if !r.isPublicIP(ip) {
|
if !r.isPublicIP(ip) {
|
||||||
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
checks := make([]api.BlacklistCheck, len(r.Lists))
|
var checks []api.BlacklistCheck
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
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
|
listedCount := 0
|
||||||
for _, check := range checks {
|
|
||||||
|
for _, list := range r.Lists {
|
||||||
|
check := r.checkIP(ip, list)
|
||||||
|
checks = append(checks, check)
|
||||||
if check.Listed {
|
if check.Listed {
|
||||||
listedCount++
|
listedCount++
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) {
|
||||||
func TestGetBlacklistScore(t *testing.T) {
|
func TestGetBlacklistScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
results *DNSListResults
|
results *RBLResults
|
||||||
expectedScore int
|
expectedScore int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
|
@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No IPs checked",
|
name: "No IPs checked",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{},
|
IPsChecked: []string{},
|
||||||
},
|
},
|
||||||
expectedScore: 100,
|
expectedScore: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Not listed on any RBL",
|
name: "Not listed on any RBL",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 0,
|
ListedCount: 0,
|
||||||
},
|
},
|
||||||
|
|
@ -290,7 +290,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 1 RBL",
|
name: "Listed on 1 RBL",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 1,
|
ListedCount: 1,
|
||||||
},
|
},
|
||||||
|
|
@ -298,7 +298,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 2 RBLs",
|
name: "Listed on 2 RBLs",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 2,
|
ListedCount: 2,
|
||||||
},
|
},
|
||||||
|
|
@ -306,7 +306,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 3 RBLs",
|
name: "Listed on 3 RBLs",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 3,
|
ListedCount: 3,
|
||||||
},
|
},
|
||||||
|
|
@ -314,7 +314,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 4+ RBLs",
|
name: "Listed on 4+ RBLs",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 4,
|
ListedCount: 4,
|
||||||
},
|
},
|
||||||
|
|
@ -335,7 +335,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetUniqueListedIPs(t *testing.T) {
|
func TestGetUniqueListedIPs(t *testing.T) {
|
||||||
results := &DNSListResults{
|
results := &RBLResults{
|
||||||
Checks: map[string][]api.BlacklistCheck{
|
Checks: map[string][]api.BlacklistCheck{
|
||||||
"198.51.100.1": {
|
"198.51.100.1": {
|
||||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||||
|
|
@ -363,7 +363,7 @@ func TestGetUniqueListedIPs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetRBLsForIP(t *testing.T) {
|
func TestGetRBLsForIP(t *testing.T) {
|
||||||
results := &DNSListResults{
|
results := &RBLResults{
|
||||||
Checks: map[string][]api.BlacklistCheck{
|
Checks: map[string][]api.BlacklistCheck{
|
||||||
"198.51.100.1": {
|
"198.51.100.1": {
|
||||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,6 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||||
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
||||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||||
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
|
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
|
||||||
report := strings.ReplaceAll(spamdResult, "; ", ";\n")
|
|
||||||
result.Report = &report
|
|
||||||
a.parseSpamdResult(spamdResult, result)
|
a.parseSpamdResult(spamdResult, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,9 +111,8 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse symbols: SYMBOL(score)[params]
|
// Parse symbols: SYMBOL(score)[params]
|
||||||
// Each symbol entry is separated by ";", so within each part we use a
|
// Each symbol entry is separated by ";"
|
||||||
// greedy match to capture params that may contain nested brackets.
|
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[([^\]]*)\])?`)
|
||||||
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`)
|
|
||||||
for _, part := range strings.Split(header, ";") {
|
for _, part := range strings.Split(header, ";") {
|
||||||
part = strings.TrimSpace(part)
|
part = strings.TrimSpace(part)
|
||||||
matches := symbolRe.FindStringSubmatch(part)
|
matches := symbolRe.FindStringSubmatch(part)
|
||||||
|
|
|
||||||
|
|
@ -1,414 +0,0 @@
|
||||||
// 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 <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
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: <test123@example.com>
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,21 +1,23 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BlacklistCheck } from "$lib/api/types.gen";
|
import type { BlacklistCheck, ReceivedHop } from "$lib/api/types.gen";
|
||||||
import { getScoreColorClass } from "$lib/score";
|
import { getScoreColorClass } from "$lib/score";
|
||||||
import { theme } from "$lib/stores/theme";
|
import { theme } from "$lib/stores/theme";
|
||||||
|
import EmailPathCard from "./EmailPathCard.svelte";
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
import GradeDisplay from "./GradeDisplay.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
blacklists: Record<string, BlacklistCheck[]>;
|
blacklists: Record<string, BlacklistCheck[]>;
|
||||||
blacklistGrade?: string;
|
blacklistGrade?: string;
|
||||||
blacklistScore?: number;
|
blacklistScore?: number;
|
||||||
|
receivedChain?: ReceivedHop[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
|
let { blacklists, blacklistGrade, blacklistScore, receivedChain }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card shadow-sm" id="rbl-details">
|
<div class="card shadow-sm" id="rbl-details">
|
||||||
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
||||||
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
|
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-shield-exclamation me-2"></i>
|
<i class="bi bi-shield-exclamation me-2"></i>
|
||||||
Blacklist Checks
|
Blacklist Checks
|
||||||
|
|
@ -33,7 +35,11 @@
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
{#if receivedChain}
|
||||||
|
<EmailPathCard {receivedChain} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-lg-2">
|
||||||
{#each Object.entries(blacklists) as [ip, checks]}
|
{#each Object.entries(blacklists) as [ip, checks]}
|
||||||
<div class="col mb-3">
|
<div class="col mb-3">
|
||||||
<h5 class="text-muted">
|
<h5 class="text-muted">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ReceivedHop } from "$lib/api/types.gen";
|
import type { ReceivedHop } from "$lib/api/types.gen";
|
||||||
import { theme } from "$lib/stores/theme";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
receivedChain: ReceivedHop[];
|
receivedChain: ReceivedHop[];
|
||||||
|
|
@ -10,18 +9,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if receivedChain && receivedChain.length > 0}
|
{#if receivedChain && receivedChain.length > 0}
|
||||||
<div class="card shadow-sm" id="email-path">
|
<div class="mb-3" id="email-path">
|
||||||
<div
|
<h5>Email Path (Received Chain)</h5>
|
||||||
class="card-header"
|
<div class="list-group">
|
||||||
class:bg-white={$theme === "light"}
|
|
||||||
class:bg-dark={$theme !== "light"}
|
|
||||||
>
|
|
||||||
<h4 class="mb-0">
|
|
||||||
<i class="bi bi-pin-map me-2"></i>
|
|
||||||
Email Path
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
{#each receivedChain as hop, i}
|
{#each receivedChain as hop, i}
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
|
@ -40,7 +30,7 @@
|
||||||
: "-"}
|
: "-"}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
{#if hop.with || hop.id || hop.from}
|
{#if hop.with || hop.id}
|
||||||
<p class="mb-1 small d-flex gap-3">
|
<p class="mb-1 small d-flex gap-3">
|
||||||
{#if hop.with}
|
{#if hop.with}
|
||||||
<span>
|
<span>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@
|
||||||
|
|
||||||
const effectiveAction = $derived.by(() => {
|
const effectiveAction = $derived.by(() => {
|
||||||
const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15;
|
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)
|
if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD)
|
||||||
return { label: "Add header", cls: "bg-warning text-dark" };
|
return { label: "Add header", cls: "bg-warning text-dark" };
|
||||||
if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD)
|
if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD)
|
||||||
|
|
@ -30,7 +31,7 @@
|
||||||
<div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}">
|
<div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}">
|
||||||
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-bug me-2"></i>
|
<i class="bi bi-shield-exclamation me-2"></i>
|
||||||
rspamd Analysis
|
rspamd Analysis
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -107,32 +108,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if rspamd.report}
|
|
||||||
<details class="mt-3">
|
|
||||||
<summary class="cursor-pointer fw-bold">Raw Report</summary>
|
|
||||||
<pre
|
|
||||||
class="mt-2 small {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} p-3 rounded">{rspamd.report}</pre>
|
|
||||||
</details>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.cursor-pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
details summary {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
details summary:hover {
|
|
||||||
color: var(--bs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Darker table colors in dark mode */
|
/* Darker table colors in dark mode */
|
||||||
:global([data-bs-theme="dark"]) .table-warning {
|
:global([data-bs-theme="dark"]) .table-warning {
|
||||||
--bs-table-bg: rgba(255, 193, 7, 0.2);
|
--bs-table-bg: rgba(255, 193, 7, 0.2);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
<div class="card shadow-sm" id="dnswl-details">
|
<div class="card shadow-sm" id="dnswl-details">
|
||||||
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
||||||
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
|
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-shield-check me-2"></i>
|
<i class="bi bi-shield-check me-2"></i>
|
||||||
Whitelist Checks
|
Whitelist Checks
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
no impact on the overall score.
|
no impact on the overall score.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
<div class="row row-cols-1 row-cols-lg-2">
|
||||||
{#each Object.entries(whitelists) as [ip, checks]}
|
{#each Object.entries(whitelists) as [ip, checks]}
|
||||||
<div class="col mb-3">
|
<div class="col mb-3">
|
||||||
<h5 class="text-muted">
|
<h5 class="text-muted">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { checkBlacklist } from "$lib/api";
|
import { checkBlacklist } from "$lib/api";
|
||||||
import type { BlacklistCheckResponse } from "$lib/api/types.gen";
|
import type { BlacklistCheckResponse } from "$lib/api/types.gen";
|
||||||
import { BlacklistCard, GradeDisplay, TinySurvey, WhitelistCard } from "$lib/components";
|
import { BlacklistCard, GradeDisplay, TinySurvey } from "$lib/components";
|
||||||
import { theme } from "$lib/stores/theme";
|
import { theme } from "$lib/stores/theme";
|
||||||
|
|
||||||
let ip = $derived($page.params.ip);
|
let ip = $derived($page.params.ip);
|
||||||
|
|
@ -122,8 +122,8 @@
|
||||||
>
|
>
|
||||||
<p class="mb-0 mt-1 small">
|
<p class="mb-0 mt-1 small">
|
||||||
This IP address is listed on {result.listed_count} of
|
This IP address is listed on {result.listed_count} of
|
||||||
{result.blacklists.length} checked blacklist{result
|
{result.checks.length} checked blacklist{result
|
||||||
.blacklists.length > 1
|
.checks.length > 1
|
||||||
? "s"
|
? "s"
|
||||||
: ""}.
|
: ""}.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -150,23 +150,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<!-- Blacklist Results Card -->
|
||||||
<!-- Blacklist Results Card -->
|
<BlacklistCard
|
||||||
<div class="col col-lg-6">
|
blacklists={{ [result.ip]: result.checks }}
|
||||||
<BlacklistCard
|
blacklistScore={result.score}
|
||||||
blacklists={{ [result.ip]: result.blacklists }}
|
blacklistGrade={result.grade}
|
||||||
blacklistScore={result.score}
|
/>
|
||||||
blacklistGrade={result.grade}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Whitelist Results Card -->
|
|
||||||
{#if result.whitelists && result.whitelists.length > 0}
|
|
||||||
<div class="col col-lg-6">
|
|
||||||
<WhitelistCard whitelists={{ [result.ip]: result.whitelists }} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Information Card -->
|
<!-- Information Card -->
|
||||||
<div class="card shadow-sm mt-4">
|
<div class="card shadow-sm mt-4">
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
BlacklistCard,
|
BlacklistCard,
|
||||||
ContentAnalysisCard,
|
ContentAnalysisCard,
|
||||||
DnsRecordsCard,
|
DnsRecordsCard,
|
||||||
EmailPathCard,
|
|
||||||
ErrorDisplay,
|
ErrorDisplay,
|
||||||
HeaderAnalysisCard,
|
HeaderAnalysisCard,
|
||||||
PendingState,
|
PendingState,
|
||||||
|
|
@ -295,15 +294,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Received Chain -->
|
|
||||||
{#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0}
|
|
||||||
<div class="row mb-4" id="received-chain">
|
|
||||||
<div class="col-12">
|
|
||||||
<EmailPathCard receivedChain={report.header_analysis.received_chain} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- DNS Records -->
|
<!-- DNS Records -->
|
||||||
{#if report.dns_results}
|
{#if report.dns_results}
|
||||||
<div class="row mb-4" id="dns">
|
<div class="row mb-4" id="dns">
|
||||||
|
|
@ -339,6 +329,7 @@
|
||||||
{blacklists}
|
{blacklists}
|
||||||
blacklistGrade={report.summary?.blacklist_grade}
|
blacklistGrade={report.summary?.blacklist_grade}
|
||||||
blacklistScore={report.summary?.blacklist_score}
|
blacklistScore={report.summary?.blacklist_score}
|
||||||
|
receivedChain={report.header_analysis?.received_chain}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
|
@ -393,12 +384,12 @@
|
||||||
{#if report.spamassassin || report.rspamd}
|
{#if report.spamassassin || report.rspamd}
|
||||||
<div class="row mb-4" id="spam">
|
<div class="row mb-4" id="spam">
|
||||||
{#if report.spamassassin}
|
{#if report.spamassassin}
|
||||||
<div class={report.rspamd ? "col col-lg-6 mb-4 mb-lg-0" : "col-12"}>
|
<div class={report.rspamd ? "col-lg-6 mb-4 mb-lg-0" : "col-12"}>
|
||||||
<SpamAssassinCard spamassassin={report.spamassassin} />
|
<SpamAssassinCard spamassassin={report.spamassassin} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if report.rspamd}
|
{#if report.rspamd}
|
||||||
<div class={report.spamassassin ? "col col-lg-6" : "col-12"}>
|
<div class={report.spamassassin ? "col-lg-6" : "col-12"}>
|
||||||
<RspamdCard rspamd={report.rspamd} />
|
<RspamdCard rspamd={report.rspamd} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue