Compare commits
8 commits
e1cb8d2d2b
...
fd499e2bb7
| Author | SHA1 | Date | |
|---|---|---|---|
| fd499e2bb7 | |||
| 16b7dcb057 | |||
| dfa38e8a26 | |||
| dee848d887 | |||
| b158336451 | |||
| a36824cf27 | |||
| 7d3009d7d0 | |||
| 5c104f3c99 |
20 changed files with 6884 additions and 80 deletions
|
|
@ -175,7 +175,8 @@ ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
|
||||||
HAPPYDELIVER_DOMAIN=happydeliver.local \
|
HAPPYDELIVER_DOMAIN=happydeliver.local \
|
||||||
HAPPYDELIVER_ADDRESS_PREFIX=test- \
|
HAPPYDELIVER_ADDRESS_PREFIX=test- \
|
||||||
HAPPYDELIVER_DNS_TIMEOUT=5s \
|
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 for persistent data
|
||||||
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
||||||
|
|
|
||||||
|
|
@ -926,6 +926,10 @@ components:
|
||||||
format: float
|
format: float
|
||||||
description: Score contribution of this test
|
description: Score contribution of this test
|
||||||
example: -1.9
|
example: -1.9
|
||||||
|
params:
|
||||||
|
type: string
|
||||||
|
description: Symbol parameters or options
|
||||||
|
example: "0.02"
|
||||||
description:
|
description:
|
||||||
type: string
|
type: string
|
||||||
description: Human-readable description of what this test checks
|
description: Human-readable description of what this test checks
|
||||||
|
|
@ -975,7 +979,7 @@ components:
|
||||||
symbols:
|
symbols:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
$ref: '#/components/schemas/RspamdSymbol'
|
$ref: '#/components/schemas/SpamTestDetail'
|
||||||
description: Map of triggered rspamd symbols to their details
|
description: Map of triggered rspamd symbols to their details
|
||||||
example:
|
example:
|
||||||
BAYES_HAM:
|
BAYES_HAM:
|
||||||
|
|
@ -986,25 +990,6 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: Full rspamd report (raw X-Spamd-Result header)
|
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:
|
DNSResults:
|
||||||
type: object
|
type: object
|
||||||
|
|
|
||||||
7
go.mod
7
go.mod
|
|
@ -5,7 +5,6 @@ go 1.25.0
|
||||||
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.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/oapi-codegen/runtime v1.3.0
|
github.com/oapi-codegen/runtime v1.3.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.134.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
|
||||||
|
|
@ -51,8 +50,8 @@ require (
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
|
|
||||||
18
go.sum
18
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=
|
||||||
|
|
@ -38,8 +34,8 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU=
|
||||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -134,10 +129,10 @@ github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQ
|
||||||
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.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
|
github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
|
||||||
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
|
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus=
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s=
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g=
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ func declareFlags(o *Config) {
|
||||||
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP 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)")
|
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.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.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.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.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,8 @@ type AnalysisConfig struct {
|
||||||
HTTPTimeout time.Duration
|
HTTPTimeout time.Duration
|
||||||
RBLs []string
|
RBLs []string
|
||||||
DNSWLs []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
|
// DefaultConfig returns a configuration with sensible defaults
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
||||||
cfg.Analysis.RBLs,
|
cfg.Analysis.RBLs,
|
||||||
cfg.Analysis.DNSWLs,
|
cfg.Analysis.DNSWLs,
|
||||||
cfg.Analysis.CheckAllIPs,
|
cfg.Analysis.CheckAllIPs,
|
||||||
|
cfg.Analysis.RspamdAPIURL,
|
||||||
)
|
)
|
||||||
|
|
||||||
return &EmailAnalyzer{
|
return &EmailAnalyzer{
|
||||||
|
|
@ -137,7 +138,7 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.Bl
|
||||||
IPsChecked: []string{ip},
|
IPsChecked: []string{ip},
|
||||||
ListedCount: listedCount,
|
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)
|
// Check the IP against all configured DNSWLs (informational only)
|
||||||
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)
|
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)
|
||||||
|
|
|
||||||
|
|
@ -152,27 +152,32 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe
|
||||||
|
|
||||||
score := 0
|
score := 0
|
||||||
|
|
||||||
// IPRev (15 points)
|
// Core authentication (90 points total)
|
||||||
score += 15 * a.calculateIPRevScore(results) / 100
|
// SPF (30 points)
|
||||||
|
score += 30 * a.calculateSPFScore(results) / 100
|
||||||
|
|
||||||
// SPF (25 points)
|
// DKIM (30 points)
|
||||||
score += 25 * a.calculateSPFScore(results) / 100
|
score += 30 * a.calculateDKIMScore(results) / 100
|
||||||
|
|
||||||
// DKIM (23 points)
|
// DMARC (30 points)
|
||||||
score += 23 * a.calculateDKIMScore(results) / 100
|
score += 30 * a.calculateDMARCScore(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
|
|
||||||
|
|
||||||
// BIMI (10 points)
|
// BIMI (10 points)
|
||||||
score += 10 * a.calculateBIMIScore(results) / 100
|
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
|
// Ensure score doesn't exceed 100
|
||||||
if score > 100 {
|
if score > 100 {
|
||||||
score = 100
|
score = 100
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,16 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe
|
||||||
return nil
|
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{}
|
result := &api.AuthResult{}
|
||||||
|
|
||||||
// Extract result (first word)
|
// Extract result (first word)
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.Header
|
||||||
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
||||||
|
|
||||||
// Check DKIM records by parsing DKIM-Signature headers directly
|
// 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)
|
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
|
||||||
if dkimRecord != nil {
|
if dkimRecord != nil {
|
||||||
if results.DkimRecords == nil {
|
if results.DkimRecords == nil {
|
||||||
|
|
|
||||||
|
|
@ -300,13 +300,24 @@ func (r *DNSListChecker) reverseIP(ipStr string) string {
|
||||||
|
|
||||||
// CalculateScore calculates the list contribution to deliverability.
|
// CalculateScore calculates the list contribution to deliverability.
|
||||||
// Informational lists are not counted in the score.
|
// 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 {
|
if results == nil || len(results.IPsChecked) == 0 {
|
||||||
return 100, ""
|
return 100, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
scoringListCount := len(r.Lists) - len(r.informationalSet)
|
if results.ListedCount <= 0 {
|
||||||
if scoringListCount <= 0 {
|
|
||||||
return 100, "A+"
|
return 100, "A+"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,12 @@ func NewReportGenerator(
|
||||||
rbls []string,
|
rbls []string,
|
||||||
dnswls []string,
|
dnswls []string,
|
||||||
checkAllIPs bool,
|
checkAllIPs bool,
|
||||||
|
rspamdAPIURL string,
|
||||||
) *ReportGenerator {
|
) *ReportGenerator {
|
||||||
return &ReportGenerator{
|
return &ReportGenerator{
|
||||||
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
|
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
|
||||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||||
rspamdAnalyzer: NewRspamdAnalyzer(),
|
rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
|
||||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||||
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
||||||
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
|
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
|
||||||
|
|
@ -140,8 +141,10 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
|
|
||||||
blacklistScore := 0
|
blacklistScore := 0
|
||||||
var blacklistGrade string
|
var blacklistGrade string
|
||||||
|
var whitelistGrade string
|
||||||
if results.RBL != nil {
|
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)
|
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
||||||
|
|
@ -172,7 +175,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
AuthenticationScore: authScore,
|
AuthenticationScore: authScore,
|
||||||
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
|
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
|
||||||
BlacklistScore: blacklistScore,
|
BlacklistScore: blacklistScore,
|
||||||
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
|
BlacklistGrade: api.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
|
||||||
ContentScore: contentScore,
|
ContentScore: contentScore,
|
||||||
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
|
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
|
||||||
HeaderScore: headerScore,
|
HeaderScore: headerScore,
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewReportGenerator(t *testing.T) {
|
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 {
|
if gen == nil {
|
||||||
t.Fatal("Expected report generator, got nil")
|
t.Fatal("Expected report generator, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnalyzeEmail(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()
|
email := createTestEmail()
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateReport(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()
|
testID := uuid.New()
|
||||||
|
|
||||||
email := createTestEmail()
|
email := createTestEmail()
|
||||||
|
|
@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateReportWithSpamAssassin(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()
|
testID := uuid.New()
|
||||||
|
|
||||||
email := createTestEmailWithSpamAssassin()
|
email := createTestEmailWithSpamAssassin()
|
||||||
|
|
@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateRawEmail(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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
21
pkg/analyzer/rspamd-symbols-README.md
Normal file
21
pkg/analyzer/rspamd-symbols-README.md
Normal file
|
|
@ -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.
|
||||||
6646
pkg/analyzer/rspamd-symbols.json
Normal file
6646
pkg/analyzer/rspamd-symbols.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -37,11 +37,13 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// RspamdAnalyzer analyzes rspamd results from email headers
|
// RspamdAnalyzer analyzes rspamd results from email headers
|
||||||
type RspamdAnalyzer struct{}
|
type RspamdAnalyzer struct {
|
||||||
|
symbols map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
// NewRspamdAnalyzer creates a new rspamd analyzer
|
// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions
|
||||||
func NewRspamdAnalyzer() *RspamdAnalyzer {
|
func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer {
|
||||||
return &RspamdAnalyzer{}
|
return &RspamdAnalyzer{symbols: symbols}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
||||||
|
|
@ -59,7 +61,7 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &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)
|
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
||||||
|
|
@ -83,6 +85,16 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||||
result.Server = &server
|
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.
|
// Derive IsSpam from score vs reject threshold.
|
||||||
if result.Threshold > 0 {
|
if result.Threshold > 0 {
|
||||||
result.IsSpam = result.Score >= result.Threshold
|
result.IsSpam = result.Score >= result.Threshold
|
||||||
|
|
@ -129,7 +141,7 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul
|
||||||
if len(matches) > 2 {
|
if len(matches) > 2 {
|
||||||
name := matches[1]
|
name := matches[1]
|
||||||
score, _ := strconv.ParseFloat(matches[2], 64)
|
score, _ := strconv.ParseFloat(matches[2], 64)
|
||||||
sym := api.RspamdSymbol{
|
sym := api.SpamTestDetail{
|
||||||
Name: name,
|
Name: name,
|
||||||
Score: float32(score),
|
Score: float32(score),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
pkg/analyzer/rspamd_symbols.go
Normal file
105
pkg/analyzer/rspamd_symbols.go
Normal file
|
|
@ -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 <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 (
|
||||||
|
_ "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)
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
|
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
|
||||||
analyzer := NewRspamdAnalyzer()
|
analyzer := NewRspamdAnalyzer(nil)
|
||||||
email := &EmailMessage{Header: make(mail.Header)}
|
email := &EmailMessage{Header: make(mail.Header)}
|
||||||
|
|
||||||
result := analyzer.AnalyzeRspamd(email)
|
result := analyzer.AnalyzeRspamd(email)
|
||||||
|
|
@ -126,12 +126,12 @@ func TestParseSpamdResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewRspamdAnalyzer()
|
analyzer := NewRspamdAnalyzer(nil)
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := &api.RspamdResult{
|
result := &api.RspamdResult{
|
||||||
Symbols: make(map[string]api.RspamdSymbol),
|
Symbols: make(map[string]api.SpamTestDetail),
|
||||||
}
|
}
|
||||||
analyzer.parseSpamdResult(tt.header, result)
|
analyzer.parseSpamdResult(tt.header, result)
|
||||||
|
|
||||||
|
|
@ -241,7 +241,7 @@ func TestAnalyzeRspamd(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewRspamdAnalyzer()
|
analyzer := NewRspamdAnalyzer(nil)
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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)
|
t.Fatalf("Failed to parse email: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewRspamdAnalyzer()
|
analyzer := NewRspamdAnalyzer(nil)
|
||||||
result := analyzer.AnalyzeRspamd(email)
|
result := analyzer.AnalyzeRspamd(email)
|
||||||
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ func ScoreToReportGrade(score int) api.ReportGrade {
|
||||||
// gradeRank returns a numeric rank for a grade (lower = worse)
|
// gradeRank returns a numeric rank for a grade (lower = worse)
|
||||||
func gradeRank(grade string) int {
|
func gradeRank(grade string) int {
|
||||||
switch grade {
|
switch grade {
|
||||||
|
case "A++":
|
||||||
|
return 7
|
||||||
case "A+":
|
case "A+":
|
||||||
return 6
|
return 6
|
||||||
case "A":
|
case "A":
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>Symbol</th>
|
<th>Symbol</th>
|
||||||
<th class="text-end">Score</th>
|
<th class="text-end">Score</th>
|
||||||
<th>Parameters</th>
|
<th>Description</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -87,7 +87,14 @@
|
||||||
? "table-success"
|
? "table-success"
|
||||||
: ""}
|
: ""}
|
||||||
>
|
>
|
||||||
<td class="font-monospace">{symbolName}</td>
|
<td>
|
||||||
|
<span class="font-monospace">{symbolName}</span>
|
||||||
|
{#if symbol.params}
|
||||||
|
<small class="d-block text-muted">
|
||||||
|
{symbol.params}
|
||||||
|
</small>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<span
|
<span
|
||||||
class={symbol.score > 0
|
class={symbol.score > 0
|
||||||
|
|
@ -99,7 +106,7 @@
|
||||||
{symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)}
|
{symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="small text-muted">{symbol.params ?? ""}</td>
|
<td class="small text-muted">{symbol.description ?? ""}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue