Compare commits
No commits in common. "master" and "f/rspamd" have entirely different histories.
57 changed files with 1230 additions and 9680 deletions
|
|
@ -170,13 +170,7 @@ 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_RSPAMD_API_URL=http://127.0.0.1:11334
|
||||
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
|
||||
|
||||
# Volume for persistent data
|
||||
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -166,24 +166,7 @@ The server will start on `http://localhost:8080` by default.
|
|||
It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ...
|
||||
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
|
||||
|
||||
#### Receiver Hostname
|
||||
|
||||
happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`).
|
||||
|
||||
If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly:
|
||||
|
||||
```bash
|
||||
./happyDeliver server -receiver-hostname mail.example.com
|
||||
```
|
||||
|
||||
Or via environment variable:
|
||||
```bash
|
||||
HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server
|
||||
```
|
||||
|
||||
**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`.
|
||||
|
||||
If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
|
||||
Choose one of the following way to integrate happyDeliver in your existing setup:
|
||||
|
||||
#### Postfix LMTP Transport
|
||||
|
||||
|
|
|
|||
|
|
@ -350,19 +350,6 @@ components:
|
|||
listed: false
|
||||
- rbl: "bl.spamcop.net"
|
||||
listed: false
|
||||
whitelists:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BlacklistCheck'
|
||||
description: Map of IP addresses to their DNS whitelist check results (informational only)
|
||||
example:
|
||||
"192.0.2.1":
|
||||
- rbl: "list.dnswl.org"
|
||||
listed: false
|
||||
- rbl: "swl.spamhaus.org"
|
||||
listed: false
|
||||
content_analysis:
|
||||
$ref: '#/components/schemas/ContentAnalysis'
|
||||
header_analysis:
|
||||
|
|
@ -789,7 +776,7 @@ components:
|
|||
properties:
|
||||
result:
|
||||
type: string
|
||||
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped]
|
||||
enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass]
|
||||
description: Authentication result
|
||||
example: "pass"
|
||||
domain:
|
||||
|
|
@ -926,10 +913,6 @@ components:
|
|||
format: float
|
||||
description: Score contribution of this test
|
||||
example: -1.9
|
||||
params:
|
||||
type: string
|
||||
description: Symbol parameters or options
|
||||
example: "0.02"
|
||||
description:
|
||||
type: string
|
||||
description: Human-readable description of what this test checks
|
||||
|
|
@ -979,17 +962,33 @@ components:
|
|||
symbols:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/SpamTestDetail'
|
||||
$ref: '#/components/schemas/RspamdSymbol'
|
||||
description: Map of triggered rspamd symbols to their details
|
||||
example:
|
||||
BAYES_HAM:
|
||||
name: "BAYES_HAM"
|
||||
score: -1.9
|
||||
params: "0.02"
|
||||
report:
|
||||
type: string
|
||||
description: Full rspamd report (raw X-Spamd-Result header)
|
||||
|
||||
RspamdSymbol:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- score
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Symbol name
|
||||
example: "BAYES_HAM"
|
||||
score:
|
||||
type: number
|
||||
format: float
|
||||
description: Score contribution of this symbol
|
||||
example: -1.9
|
||||
params:
|
||||
type: string
|
||||
description: Symbol parameters or options
|
||||
example: "0.02"
|
||||
|
||||
DNSResults:
|
||||
type: object
|
||||
|
|
@ -1331,7 +1330,7 @@ components:
|
|||
type: object
|
||||
required:
|
||||
- ip
|
||||
- blacklists
|
||||
- checks
|
||||
- listed_count
|
||||
- score
|
||||
- grade
|
||||
|
|
@ -1340,7 +1339,7 @@ components:
|
|||
type: string
|
||||
description: The IP address that was checked
|
||||
example: "192.0.2.1"
|
||||
blacklists:
|
||||
checks:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BlacklistCheck'
|
||||
|
|
@ -1360,8 +1359,3 @@ 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)
|
||||
|
|
|
|||
|
|
@ -110,38 +110,14 @@ Default configuration for the Docker environment:
|
|||
The container accepts these environment variables:
|
||||
|
||||
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
|
||||
- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below)
|
||||
- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP
|
||||
|
||||
### Receiver Hostname
|
||||
Note that the hostname of the container is used to filter the authentication tests results.
|
||||
|
||||
happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`).
|
||||
|
||||
In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically.
|
||||
|
||||
**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-e HAPPYDELIVER_DOMAIN=example.com \
|
||||
-e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \
|
||||
...
|
||||
```
|
||||
|
||||
To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`.
|
||||
|
||||
If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
|
||||
|
||||
Example (all-in-one, no override needed):
|
||||
Example:
|
||||
```bash
|
||||
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
|
||||
```
|
||||
|
||||
Example (external MTA integration):
|
||||
```bash
|
||||
docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ...
|
||||
```
|
||||
|
||||
## Volumes
|
||||
|
||||
**Required volumes:**
|
||||
|
|
|
|||
|
|
@ -48,14 +48,3 @@ rbl_timeout 5
|
|||
# Don't use user-specific rules
|
||||
user_scores_dsn_timeout 3
|
||||
user_scores_sql_override 0
|
||||
|
||||
# Disable Validity network rules
|
||||
dns_query_restriction deny sa-trusted.bondedsender.org
|
||||
dns_query_restriction deny sa-accredit.habeas.com
|
||||
dns_query_restriction deny bl.score.senderscore.com
|
||||
score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0
|
||||
score RCVD_IN_VALIDITY_RPBL_BLOCKED 0
|
||||
score RCVD_IN_VALIDITY_SAFE_BLOCKED 0
|
||||
score RCVD_IN_VALIDITY_CERTIFIED 0
|
||||
score RCVD_IN_VALIDITY_RPBL 0
|
||||
score RCVD_IN_VALIDITY_SAFE 0
|
||||
22
go.mod
22
go.mod
|
|
@ -1,15 +1,15 @@
|
|||
module git.happydns.org/happyDeliver
|
||||
|
||||
go 1.25.0
|
||||
go 1.24.6
|
||||
|
||||
require (
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/getkin/kin-openapi v0.133.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/oapi-codegen/runtime v1.3.0
|
||||
golang.org/x/net v0.52.0
|
||||
github.com/oapi-codegen/runtime v1.1.2
|
||||
golang.org/x/net v0.50.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
|
|
@ -64,14 +64,14 @@ require (
|
|||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
|
|
|||
38
go.sum
38
go.sum
|
|
@ -42,8 +42,8 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz
|
|||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
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-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
|
|
@ -132,8 +132,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
|||
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/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
||||
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.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
|
||||
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||
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/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
|
|
@ -194,8 +194,6 @@ github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN
|
|||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
|
|
@ -203,11 +201,11 @@ golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
|||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
|
|
@ -215,13 +213,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
|||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
|
@ -237,21 +235,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
|
|||
|
|
@ -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, 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
|
||||
|
|
@ -359,7 +359,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
c.JSON(http.StatusBadRequest, Error{
|
||||
Error: "invalid_ip",
|
||||
|
|
@ -372,8 +372,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
|||
// Build response
|
||||
response := BlacklistCheckResponse{
|
||||
Ip: request.Ip,
|
||||
Blacklists: checks,
|
||||
Whitelists: &whitelists,
|
||||
Checks: checks,
|
||||
ListedCount: listedCount,
|
||||
Score: score,
|
||||
Grade: BlacklistCheckResponseGrade(grade),
|
||||
|
|
|
|||
|
|
@ -34,12 +34,10 @@ func declareFlags(o *Config) {
|
|||
flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
|
||||
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
|
||||
flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address")
|
||||
flag.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())")
|
||||
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
|
||||
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
|
||||
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
|
||||
flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)")
|
||||
flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)")
|
||||
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
|
||||
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
|
||||
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
|
||||
|
|
|
|||
|
|
@ -34,11 +34,6 @@ import (
|
|||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
func getHostname() string {
|
||||
h, _ := os.Hostname()
|
||||
return h
|
||||
}
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
DevProxy string
|
||||
|
|
@ -63,7 +58,6 @@ type EmailConfig struct {
|
|||
Domain string
|
||||
TestAddressPrefix string
|
||||
LMTPAddr string
|
||||
ReceiverHostname string
|
||||
}
|
||||
|
||||
// AnalysisConfig contains timeout and behavior settings for email analysis
|
||||
|
|
@ -71,9 +65,7 @@ type AnalysisConfig struct {
|
|||
DNSTimeout time.Duration
|
||||
HTTPTimeout time.Duration
|
||||
RBLs []string
|
||||
DNSWLs []string
|
||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||
RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list)
|
||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||
}
|
||||
|
||||
// DefaultConfig returns a configuration with sensible defaults
|
||||
|
|
@ -91,13 +83,11 @@ func DefaultConfig() *Config {
|
|||
Domain: "happydeliver.local",
|
||||
TestAddressPrefix: "test-",
|
||||
LMTPAddr: "127.0.0.1:2525",
|
||||
ReceiverHostname: getHostname(),
|
||||
},
|
||||
Analysis: AnalysisConfig{
|
||||
DNSTimeout: 5 * time.Second,
|
||||
HTTPTimeout: 10 * time.Second,
|
||||
RBLs: []string{},
|
||||
DNSWLs: []string{},
|
||||
CheckAllIPs: false, // By default, only check the first IP
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,17 +98,6 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
|
|||
|
||||
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
|
||||
|
||||
// Warn if the last Received hop doesn't match the expected receiver hostname
|
||||
if r.config.Email.ReceiverHostname != "" &&
|
||||
result.Report.HeaderAnalysis != nil &&
|
||||
result.Report.HeaderAnalysis.ReceivedChain != nil &&
|
||||
len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 {
|
||||
lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0]
|
||||
if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname {
|
||||
log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname)
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal report to JSON
|
||||
reportJSON, err := json.Marshal(result.Report)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -41,13 +41,10 @@ type EmailAnalyzer struct {
|
|||
// NewEmailAnalyzer creates a new email analyzer with the given configuration
|
||||
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
||||
generator := NewReportGenerator(
|
||||
cfg.Email.ReceiverHostname,
|
||||
cfg.Analysis.DNSTimeout,
|
||||
cfg.Analysis.HTTPTimeout,
|
||||
cfg.Analysis.RBLs,
|
||||
cfg.Analysis.DNSWLs,
|
||||
cfg.Analysis.CheckAllIPs,
|
||||
cfg.Analysis.RspamdAPIURL,
|
||||
)
|
||||
|
||||
return &EmailAnalyzer{
|
||||
|
|
@ -123,28 +120,22 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string)
|
|||
return dnsResults, score, grade
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// CheckBlacklistIP checks a single IP address against DNS blacklists
|
||||
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]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, nil, 0, 0, "", err
|
||||
return nil, 0, 0, "", err
|
||||
}
|
||||
|
||||
// Calculate score using the existing function
|
||||
// Create a minimal RBLResults structure for scoring
|
||||
results := &DNSListResults{
|
||||
results := &RBLResults{
|
||||
Checks: map[string][]api.BlacklistCheck{ip: checks},
|
||||
IPsChecked: []string{ip},
|
||||
ListedCount: listedCount,
|
||||
}
|
||||
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false)
|
||||
score, grade := a.analyzer.generator.rblChecker.CalculateRBLScore(results)
|
||||
|
||||
// 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
|
||||
return checks, listedCount, score, grade, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,11 @@ import (
|
|||
)
|
||||
|
||||
// AuthenticationAnalyzer analyzes email authentication results
|
||||
type AuthenticationAnalyzer struct {
|
||||
receiverHostname string
|
||||
}
|
||||
type AuthenticationAnalyzer struct{}
|
||||
|
||||
// NewAuthenticationAnalyzer creates a new authentication analyzer
|
||||
func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer {
|
||||
return &AuthenticationAnalyzer{receiverHostname: receiverHostname}
|
||||
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
|
||||
return &AuthenticationAnalyzer{}
|
||||
}
|
||||
|
||||
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
||||
|
|
@ -42,7 +40,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
|
|||
results := &api.AuthenticationResults{}
|
||||
|
||||
// Parse Authentication-Results headers
|
||||
authHeaders := email.GetAuthenticationResults(a.receiverHostname)
|
||||
authHeaders := email.GetAuthenticationResults()
|
||||
for _, header := range authHeaders {
|
||||
a.parseAuthenticationResultsHeader(header, results)
|
||||
}
|
||||
|
|
@ -152,32 +150,27 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe
|
|||
|
||||
score := 0
|
||||
|
||||
// Core authentication (90 points total)
|
||||
// SPF (30 points)
|
||||
score += 30 * a.calculateSPFScore(results) / 100
|
||||
// IPRev (15 points)
|
||||
score += 15 * a.calculateIPRevScore(results) / 100
|
||||
|
||||
// DKIM (30 points)
|
||||
score += 30 * a.calculateDKIMScore(results) / 100
|
||||
// SPF (25 points)
|
||||
score += 25 * a.calculateSPFScore(results) / 100
|
||||
|
||||
// DMARC (30 points)
|
||||
score += 30 * a.calculateDMARCScore(results) / 100
|
||||
// DKIM (23 points)
|
||||
score += 23 * a.calculateDKIMScore(results) / 100
|
||||
|
||||
// X-Google-DKIM (optional) - penalty if failed
|
||||
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
|
||||
|
||||
// X-Aligned-From
|
||||
score += 2 * a.calculateXAlignedFromScore(results) / 100
|
||||
|
||||
// DMARC (25 points)
|
||||
score += 25 * a.calculateDMARCScore(results) / 100
|
||||
|
||||
// BIMI (10 points)
|
||||
score += 10 * a.calculateBIMIScore(results) / 100
|
||||
|
||||
// Penalty-only: IPRev (up to -7 points on failure)
|
||||
if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 {
|
||||
score += 7 * (iprevScore - 100) / 100
|
||||
}
|
||||
|
||||
// Penalty-only: X-Google-DKIM (up to -12 points on failure)
|
||||
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
|
||||
|
||||
// Penalty-only: X-Aligned-From (up to -5 points on failure)
|
||||
if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 {
|
||||
score += 5 * (xAlignedScore - 100) / 100
|
||||
}
|
||||
|
||||
// Ensure score doesn't exceed 100
|
||||
if score > 100 {
|
||||
score = 100
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ func TestParseARCResult(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -136,7 +136,7 @@ func TestValidateARCChain(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func TestParseBIMIResult(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ func TestParseDKIMResult(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ func TestParseDMARCResult(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -69,5 +69,5 @@ func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.Authentication
|
|||
}
|
||||
}
|
||||
|
||||
return 100
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ func TestParseIPRevResult(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -181,7 +181,7 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -63,16 +63,6 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe
|
|||
return nil
|
||||
}
|
||||
|
||||
// Verify receiver matches our hostname
|
||||
if a.receiverHostname != "" {
|
||||
receiverRe := regexp.MustCompile(`receiver=([^\s;]+)`)
|
||||
if matches := receiverRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
|
||||
if matches[1] != a.receiverHostname {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := &api.AuthResult{}
|
||||
|
||||
// Extract result (first word)
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ func TestParseSPFResult(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -161,7 +161,7 @@ func TestParseLegacySPF(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ func TestGetAuthenticationScore(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
scorer := NewAuthenticationAnalyzer("")
|
||||
scorer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -247,7 +247,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -353,7 +353,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
|||
|
||||
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||
// This test verifies that only the first occurrence of each auth method is parsed
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) {
|
||||
header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com"
|
||||
|
|
|
|||
|
|
@ -61,5 +61,5 @@ func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.Authent
|
|||
}
|
||||
}
|
||||
|
||||
return 100
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ func TestParseXAlignedFromResult(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -126,7 +126,7 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ func TestParseXGoogleDKIMResult(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DN
|
|||
}
|
||||
|
||||
// AnalyzeDNS performs DNS validation for the email's domain
|
||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.HeaderAnalysis) *api.DNSResults {
|
||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults {
|
||||
// Extract domain from From address
|
||||
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
|
||||
return &api.DNSResults{
|
||||
|
|
@ -104,14 +104,19 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.Header
|
|||
// SPF validates the MAIL FROM command, which corresponds to Return-Path
|
||||
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
||||
|
||||
// Check DKIM records by parsing DKIM-Signature headers directly
|
||||
for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) {
|
||||
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
|
||||
if dkimRecord != nil {
|
||||
if results.DkimRecords == nil {
|
||||
results.DkimRecords = new([]api.DKIMRecord)
|
||||
// Check DKIM records (from authentication results)
|
||||
// DKIM can be for any domain, but typically the From domain
|
||||
if authResults != nil && authResults.Dkim != nil {
|
||||
for _, dkim := range *authResults.Dkim {
|
||||
if dkim.Domain != nil && dkim.Selector != nil {
|
||||
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
|
||||
if dkimRecord != nil {
|
||||
if results.DkimRecords == nil {
|
||||
results.DkimRecords = new([]api.DKIMRecord)
|
||||
}
|
||||
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
||||
}
|
||||
}
|
||||
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,38 +29,6 @@ import (
|
|||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header.
|
||||
type DKIMHeader struct {
|
||||
Domain string
|
||||
Selector string
|
||||
}
|
||||
|
||||
// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values.
|
||||
func parseDKIMSignatures(signatures []string) []DKIMHeader {
|
||||
var results []DKIMHeader
|
||||
for _, sig := range signatures {
|
||||
var domain, selector string
|
||||
for _, part := range strings.Split(sig, ";") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(kv[0])
|
||||
val := strings.TrimSpace(kv[1])
|
||||
switch key {
|
||||
case "d":
|
||||
domain = val
|
||||
case "s":
|
||||
selector = val
|
||||
}
|
||||
}
|
||||
if domain != "" && selector != "" {
|
||||
results = append(results, DKIMHeader{Domain: domain, Selector: selector})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
|
||||
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
|
||||
// DKIM records are at: selector._domainkey.domain
|
||||
|
|
|
|||
|
|
@ -26,220 +26,6 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
func TestParseDKIMSignatures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
signatures []string
|
||||
expected []DKIMHeader
|
||||
}{
|
||||
{
|
||||
name: "Empty input",
|
||||
signatures: nil,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
signatures: []string{""},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "Simple Gmail-style",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}},
|
||||
},
|
||||
{
|
||||
name: "Microsoft 365 style",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}},
|
||||
},
|
||||
{
|
||||
name: "Tab-folded multiline (Postfix-style)",
|
||||
signatures: []string{
|
||||
"v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==",
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}},
|
||||
},
|
||||
{
|
||||
name: "Space-folded multiline (RFC-style)",
|
||||
signatures: []string{
|
||||
"v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==",
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}},
|
||||
},
|
||||
{
|
||||
name: "d= and s= on separate continuation lines",
|
||||
signatures: []string{
|
||||
"v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==",
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}},
|
||||
},
|
||||
{
|
||||
name: "No space after semicolons",
|
||||
signatures: []string{
|
||||
`v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}},
|
||||
},
|
||||
{
|
||||
name: "Multiple spaces after semicolons",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}},
|
||||
},
|
||||
{
|
||||
name: "Ed25519 signature (RFC 8463)",
|
||||
signatures: []string{
|
||||
"v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==",
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}},
|
||||
},
|
||||
{
|
||||
name: "Multiple signatures (ESP double-signing)",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mydomain.com; s=mail; h=from:to:subject; bh=hash1=; b=sig1==`,
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`,
|
||||
},
|
||||
expected: []DKIMHeader{
|
||||
{Domain: "mydomain.com", Selector: "mail"},
|
||||
{Domain: "sendib.com", Selector: "mail"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Dual-algorithm signing (Ed25519 + RSA, same domain, different selectors)",
|
||||
signatures: []string{
|
||||
`v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; s=brisbane; h=from:to:subject; bh=hash=; b=edSig==`,
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`,
|
||||
},
|
||||
expected: []DKIMHeader{
|
||||
{Domain: "football.example.com", Selector: "brisbane"},
|
||||
{Domain: "football.example.com", Selector: "test"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Amazon SES long selectors",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/simple; d=amazonses.com; s=224i4yxa5dv7c2xz3womw6peuabd; h=from:to:subject; bh=sesHash=; b=sesSig==`,
|
||||
`v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`,
|
||||
},
|
||||
expected: []DKIMHeader{
|
||||
{Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"},
|
||||
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Subdomain in d=",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}},
|
||||
},
|
||||
{
|
||||
name: "Deeply nested subdomain",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}},
|
||||
},
|
||||
{
|
||||
name: "Selector with hyphens (Microsoft 365 custom domain style)",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}},
|
||||
},
|
||||
{
|
||||
name: "Selector with dots",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}},
|
||||
},
|
||||
{
|
||||
name: "Single-character selector",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}},
|
||||
},
|
||||
{
|
||||
name: "Postmark-style timestamp selector, s= before d=",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}},
|
||||
},
|
||||
{
|
||||
name: "d= and s= at the very end",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}},
|
||||
},
|
||||
{
|
||||
name: "Full tag set",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`,
|
||||
},
|
||||
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}},
|
||||
},
|
||||
{
|
||||
name: "Missing d= tag",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=selector1; h=from:to; bh=hash=; b=sig==`,
|
||||
},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "Missing s= tag",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; h=from:to; bh=hash=; b=sig==`,
|
||||
},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "Missing both d= and s= tags",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to; bh=hash=; b=sig==`,
|
||||
},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "Mix of valid and invalid signatures",
|
||||
signatures: []string{
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=good.com; s=sel1; h=from:to; bh=hash=; b=sig==`,
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=orphan; h=from:to; bh=hash=; b=sig==`,
|
||||
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`,
|
||||
},
|
||||
expected: []DKIMHeader{
|
||||
{Domain: "good.com", Selector: "sel1"},
|
||||
{Domain: "also-good.com", Selector: "sel2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parseDKIMSignatures(tt.signatures)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Fatalf("parseDKIMSignatures() returned %d results, want %d\n got: %+v\n want: %+v", len(result), len(tt.expected), result, tt.expected)
|
||||
}
|
||||
for i := range tt.expected {
|
||||
if result[i].Domain != tt.expected[i].Domain {
|
||||
t.Errorf("result[%d].Domain = %q, want %q", i, result[i].Domain, tt.expected[i].Domain)
|
||||
}
|
||||
if result[i].Selector != tt.expected[i].Selector {
|
||||
t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDKIM(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -109,13 +109,6 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
|
|||
maxGrade -= 1
|
||||
}
|
||||
|
||||
// Check MIME-Version header (-5 points if present but not "1.0")
|
||||
if check, exists := headers["mime-version"]; exists && check.Present {
|
||||
if check.Valid != nil && !*check.Valid {
|
||||
score -= 5
|
||||
}
|
||||
}
|
||||
|
||||
// Check Message-ID format (10 points)
|
||||
if check, exists := headers["message-id"]; exists && check.Present {
|
||||
// If Valid is set and true, award points
|
||||
|
|
@ -273,10 +266,6 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults
|
|||
headers[strings.ToLower(headerName)] = *check
|
||||
}
|
||||
|
||||
// Check MIME-Version header (recommended but absence is not penalized)
|
||||
mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended")
|
||||
headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck
|
||||
|
||||
// Check optional headers
|
||||
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"}
|
||||
for _, headerName := range optionalHeaders {
|
||||
|
|
@ -331,21 +320,12 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
|
|||
valid = false
|
||||
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
|
||||
}
|
||||
if len(email.Header["Message-Id"]) > 1 {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"])))
|
||||
}
|
||||
case "Date":
|
||||
// Validate date format
|
||||
if _, err := h.parseEmailDate(value); err != nil {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err))
|
||||
}
|
||||
case "MIME-Version":
|
||||
if value != "1.0" {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value))
|
||||
}
|
||||
case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path":
|
||||
// Parse address header using net/mail and get normalized address
|
||||
if normalizedAddr, err := h.validateAddressHeader(value); err != nil {
|
||||
|
|
|
|||
|
|
@ -28,9 +28,16 @@ import (
|
|||
"mime/multipart"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var hostname = ""
|
||||
|
||||
func init() {
|
||||
hostname, _ = os.Hostname()
|
||||
}
|
||||
|
||||
// EmailMessage represents a parsed email message
|
||||
type EmailMessage struct {
|
||||
Header mail.Header
|
||||
|
|
@ -211,18 +218,18 @@ func buildRawHeaders(header mail.Header) string {
|
|||
}
|
||||
|
||||
// GetAuthenticationResults extracts Authentication-Results headers
|
||||
// If receiverHostname is provided, only returns headers that begin with that hostname
|
||||
func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string {
|
||||
// If hostname is provided, only returns headers that begin with that hostname
|
||||
func (e *EmailMessage) GetAuthenticationResults() []string {
|
||||
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
||||
|
||||
// If no hostname specified, return all results
|
||||
if receiverHostname == "" {
|
||||
if hostname == "" {
|
||||
return allResults
|
||||
}
|
||||
|
||||
// Filter results that begin with the specified hostname
|
||||
var filtered []string
|
||||
prefix := receiverHostname + ";"
|
||||
prefix := hostname + ";"
|
||||
for _, result := range allResults {
|
||||
// Trim whitespace and check if it starts with hostname;
|
||||
trimmed := strings.TrimSpace(result)
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ Content-Type: text/html; charset=utf-8
|
|||
}
|
||||
|
||||
func TestGetAuthenticationResults(t *testing.T) {
|
||||
// Force hostname
|
||||
hostname = "example.com"
|
||||
|
||||
rawEmail := `From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test Email
|
||||
|
|
@ -120,7 +123,7 @@ Body content.
|
|||
t.Fatalf("Failed to parse email: %v", err)
|
||||
}
|
||||
|
||||
authResults := email.GetAuthenticationResults("example.com")
|
||||
authResults := email.GetAuthenticationResults()
|
||||
if len(authResults) != 2 {
|
||||
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,21 +27,17 @@ import (
|
|||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
|
||||
// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
|
||||
type DNSListChecker struct {
|
||||
Timeout time.Duration
|
||||
Lists []string
|
||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||
filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
|
||||
resolver *net.Resolver
|
||||
informationalSet map[string]bool // Lists whose hits don't count toward the score
|
||||
// RBLChecker checks IP addresses against DNS-based blacklists
|
||||
type RBLChecker struct {
|
||||
Timeout time.Duration
|
||||
RBLs []string
|
||||
CheckAllIPs bool // Check all IPs found in headers, not just the first one
|
||||
resolver *net.Resolver
|
||||
}
|
||||
|
||||
// DefaultRBLs is a list of commonly used RBL providers
|
||||
|
|
@ -52,83 +48,40 @@ var DefaultRBLs = []string{
|
|||
"b.barracudacentral.org", // Barracuda
|
||||
"cbl.abuseat.org", // CBL (Composite Blocking List)
|
||||
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
||||
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
|
||||
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
|
||||
"psbl.surriel.com", // PSBL
|
||||
"dnsbl.dronebl.org", // DroneBL
|
||||
"bl.mailspike.net", // Mailspike BL
|
||||
"z.mailspike.net", // Mailspike Z
|
||||
"bl.rbl-dns.com", // RBL-DNS
|
||||
"bl.nszones.com", // NSZones
|
||||
}
|
||||
|
||||
// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score.
|
||||
// These are typically broader lists where being listed is less definitive.
|
||||
var DefaultInformationalRBLs = []string{
|
||||
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives
|
||||
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring
|
||||
}
|
||||
|
||||
// DefaultDNSWLs is a list of commonly used DNSWL providers
|
||||
var DefaultDNSWLs = []string{
|
||||
"list.dnswl.org", // DNSWL.org — the main DNS whitelist
|
||||
"swl.spamhaus.org", // Spamhaus Safe Whitelist
|
||||
}
|
||||
|
||||
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
|
||||
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
|
||||
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLChecker {
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
timeout = 5 * time.Second // Default timeout
|
||||
}
|
||||
if len(rbls) == 0 {
|
||||
rbls = DefaultRBLs
|
||||
}
|
||||
informationalSet := make(map[string]bool, len(DefaultInformationalRBLs))
|
||||
for _, rbl := range DefaultInformationalRBLs {
|
||||
informationalSet[rbl] = true
|
||||
}
|
||||
return &DNSListChecker{
|
||||
Timeout: timeout,
|
||||
Lists: rbls,
|
||||
CheckAllIPs: checkAllIPs,
|
||||
filterErrorCodes: true,
|
||||
resolver: &net.Resolver{PreferGo: true},
|
||||
informationalSet: informationalSet,
|
||||
return &RBLChecker{
|
||||
Timeout: timeout,
|
||||
RBLs: rbls,
|
||||
CheckAllIPs: checkAllIPs,
|
||||
resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list
|
||||
func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker {
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if len(dnswls) == 0 {
|
||||
dnswls = DefaultDNSWLs
|
||||
}
|
||||
return &DNSListChecker{
|
||||
Timeout: timeout,
|
||||
Lists: dnswls,
|
||||
CheckAllIPs: checkAllIPs,
|
||||
filterErrorCodes: false,
|
||||
resolver: &net.Resolver{PreferGo: true},
|
||||
informationalSet: make(map[string]bool),
|
||||
}
|
||||
// RBLResults represents the results of RBL checks
|
||||
type RBLResults struct {
|
||||
Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP
|
||||
IPsChecked []string
|
||||
ListedCount int
|
||||
}
|
||||
|
||||
// DNSListResults represents the results of DNS list checks
|
||||
type DNSListResults struct {
|
||||
Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP
|
||||
IPsChecked []string
|
||||
ListedCount int // Total listings including informational entries
|
||||
RelevantListedCount int // Listings on scoring (non-informational) lists only
|
||||
}
|
||||
|
||||
// CheckEmail checks all IPs found in the email headers against the configured lists
|
||||
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||
results := &DNSListResults{
|
||||
// CheckEmail checks all IPs found in the email headers against RBLs
|
||||
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
|
||||
results := &RBLResults{
|
||||
Checks: make(map[string][]api.BlacklistCheck),
|
||||
}
|
||||
|
||||
// Extract IPs from Received headers
|
||||
ips := r.extractIPs(email)
|
||||
if len(ips) == 0 {
|
||||
return results
|
||||
|
|
@ -136,18 +89,17 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
|||
|
||||
results.IPsChecked = ips
|
||||
|
||||
// Check each IP against all RBLs
|
||||
for _, ip := range ips {
|
||||
for _, list := range r.Lists {
|
||||
check := r.checkIP(ip, list)
|
||||
for _, rbl := range r.RBLs {
|
||||
check := r.checkIP(ip, rbl)
|
||||
results.Checks[ip] = append(results.Checks[ip], check)
|
||||
if check.Listed {
|
||||
results.ListedCount++
|
||||
if !r.informationalSet[list] {
|
||||
results.RelevantListedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only check the first IP unless CheckAllIPs is enabled
|
||||
if !r.CheckAllIPs {
|
||||
break
|
||||
}
|
||||
|
|
@ -156,26 +108,20 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
|||
return results
|
||||
}
|
||||
|
||||
// CheckIP checks a single IP address against all configured lists in parallel
|
||||
func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
||||
// CheckIP checks a single IP address against all configured RBLs
|
||||
func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
||||
// Validate that it's a valid IP address
|
||||
if !r.isPublicIP(ip) {
|
||||
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
||||
}
|
||||
|
||||
checks := make([]api.BlacklistCheck, len(r.Lists))
|
||||
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()
|
||||
|
||||
var checks []api.BlacklistCheck
|
||||
listedCount := 0
|
||||
for _, check := range checks {
|
||||
|
||||
// Check the IP against all RBLs
|
||||
for _, rbl := range r.RBLs {
|
||||
check := r.checkIP(ip, rbl)
|
||||
checks = append(checks, check)
|
||||
if check.Listed {
|
||||
listedCount++
|
||||
}
|
||||
|
|
@ -185,19 +131,27 @@ func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
|||
}
|
||||
|
||||
// extractIPs extracts IP addresses from Received headers
|
||||
func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
|
||||
func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
|
||||
var ips []string
|
||||
seenIPs := make(map[string]bool)
|
||||
|
||||
// Get all Received headers
|
||||
receivedHeaders := email.Header["Received"]
|
||||
|
||||
// Regex patterns for IP addresses
|
||||
// Match IPv4: xxx.xxx.xxx.xxx
|
||||
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
|
||||
|
||||
// Look for IPs in Received headers
|
||||
for _, received := range receivedHeaders {
|
||||
// Find all IPv4 addresses
|
||||
matches := ipv4Pattern.FindAllString(received, -1)
|
||||
for _, match := range matches {
|
||||
// Skip private/reserved IPs
|
||||
if !r.isPublicIP(match) {
|
||||
continue
|
||||
}
|
||||
// Avoid duplicates
|
||||
if !seenIPs[match] {
|
||||
ips = append(ips, match)
|
||||
seenIPs[match] = true
|
||||
|
|
@ -205,10 +159,13 @@ func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
|
|||
}
|
||||
}
|
||||
|
||||
// If no IPs found in Received headers, try X-Originating-IP
|
||||
if len(ips) == 0 {
|
||||
originatingIP := email.Header.Get("X-Originating-IP")
|
||||
if originatingIP != "" {
|
||||
// Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
|
||||
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
|
||||
// Remove any whitespace
|
||||
cleanIP = strings.TrimSpace(cleanIP)
|
||||
matches := ipv4Pattern.FindString(cleanIP)
|
||||
if matches != "" && r.isPublicIP(matches) {
|
||||
|
|
@ -221,16 +178,19 @@ func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
|
|||
}
|
||||
|
||||
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
|
||||
func (r *DNSListChecker) isPublicIP(ipStr string) bool {
|
||||
func (r *RBLChecker) isPublicIP(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a private network
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Additional checks for reserved ranges
|
||||
// 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
|
||||
if ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
|
|
@ -238,43 +198,51 @@ func (r *DNSListChecker) isPublicIP(ipStr string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// checkIP checks a single IP against a single DNS list
|
||||
func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
|
||||
// checkIP checks a single IP against a single RBL
|
||||
func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
|
||||
check := api.BlacklistCheck{
|
||||
Rbl: list,
|
||||
Rbl: rbl,
|
||||
}
|
||||
|
||||
// Reverse the IP for DNSBL query
|
||||
reversedIP := r.reverseIP(ip)
|
||||
if reversedIP == "" {
|
||||
check.Error = api.PtrTo("Failed to reverse IP address")
|
||||
return check
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("%s.%s", reversedIP, list)
|
||||
// Construct DNSBL query: reversed-ip.rbl-domain
|
||||
query := fmt.Sprintf("%s.%s", reversedIP, rbl)
|
||||
|
||||
// Perform DNS lookup with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := r.resolver.LookupHost(ctx, query)
|
||||
if err != nil {
|
||||
// Most likely not listed (NXDOMAIN)
|
||||
if dnsErr, ok := err.(*net.DNSError); ok {
|
||||
if dnsErr.IsNotFound {
|
||||
check.Listed = false
|
||||
return check
|
||||
}
|
||||
}
|
||||
// Other DNS errors
|
||||
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
||||
return check
|
||||
}
|
||||
|
||||
// If we got a response, check the return code
|
||||
if len(addrs) > 0 {
|
||||
check.Response = api.PtrTo(addrs[0])
|
||||
check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2)
|
||||
|
||||
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
|
||||
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
|
||||
// Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255
|
||||
// These indicate RBL operational issues, not actual listings
|
||||
if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" {
|
||||
check.Listed = false
|
||||
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
|
||||
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0]))
|
||||
} else {
|
||||
// Normal listing response
|
||||
check.Listed = true
|
||||
}
|
||||
}
|
||||
|
|
@ -282,58 +250,44 @@ func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
|
|||
return check
|
||||
}
|
||||
|
||||
// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
|
||||
// reverseIP reverses an IPv4 address for DNSBL queries
|
||||
// Example: 192.0.2.1 -> 1.2.0.192
|
||||
func (r *DNSListChecker) reverseIP(ipStr string) string {
|
||||
func (r *RBLChecker) reverseIP(ipStr string) string {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Convert to IPv4
|
||||
ipv4 := ip.To4()
|
||||
if ipv4 == nil {
|
||||
return "" // IPv6 not supported yet
|
||||
}
|
||||
|
||||
// Reverse the octets
|
||||
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
|
||||
}
|
||||
|
||||
// CalculateScore calculates the list contribution to deliverability.
|
||||
// Informational lists are not counted in the score.
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateRBLScore calculates the blacklist contribution to deliverability
|
||||
func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) {
|
||||
if results == nil || len(results.IPsChecked) == 0 {
|
||||
// No IPs to check, give benefit of doubt
|
||||
return 100, ""
|
||||
}
|
||||
|
||||
if results.ListedCount <= 0 {
|
||||
return 100, "A+"
|
||||
}
|
||||
|
||||
percentage := 100 - results.RelevantListedCount*100/scoringListCount
|
||||
percentage := 100 - results.ListedCount*100/len(r.RBLs)
|
||||
return percentage, ScoreToGrade(percentage)
|
||||
}
|
||||
|
||||
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
|
||||
func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
|
||||
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
|
||||
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
|
||||
var listedIPs []string
|
||||
|
||||
for ip, checks := range results.Checks {
|
||||
for _, check := range checks {
|
||||
for ip, rblChecks := range results.Checks {
|
||||
for _, check := range rblChecks {
|
||||
if check.Listed {
|
||||
listedIPs = append(listedIPs, ip)
|
||||
break
|
||||
break // Only add the IP once
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -341,17 +295,17 @@ func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
|
|||
return listedIPs
|
||||
}
|
||||
|
||||
// GetListsForIP returns all lists that match a specific IP
|
||||
func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
|
||||
var lists []string
|
||||
// GetRBLsForIP returns all RBLs that list a specific IP
|
||||
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
|
||||
var rbls []string
|
||||
|
||||
if checks, exists := results.Checks[ip]; exists {
|
||||
for _, check := range checks {
|
||||
if rblChecks, exists := results.Checks[ip]; exists {
|
||||
for _, check := range rblChecks {
|
||||
if check.Listed {
|
||||
lists = append(lists, check.Rbl)
|
||||
rbls = append(rbls, check.Rbl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lists
|
||||
return rbls
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ func TestNewRBLChecker(t *testing.T) {
|
|||
if checker.Timeout != tt.expectedTimeout {
|
||||
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
|
||||
}
|
||||
if len(checker.Lists) != tt.expectedRBLs {
|
||||
t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs)
|
||||
if len(checker.RBLs) != tt.expectedRBLs {
|
||||
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
|
||||
}
|
||||
if checker.resolver == nil {
|
||||
t.Error("Resolver should not be nil")
|
||||
|
|
@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) {
|
|||
func TestGetBlacklistScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *DNSListResults
|
||||
results *RBLResults
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
|
|
@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "No IPs checked",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{},
|
||||
},
|
||||
expectedScore: 100,
|
||||
},
|
||||
{
|
||||
name: "Not listed on any RBL",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 0,
|
||||
},
|
||||
|
|
@ -290,7 +290,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "Listed on 1 RBL",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 1,
|
||||
},
|
||||
|
|
@ -298,7 +298,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "Listed on 2 RBLs",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 2,
|
||||
},
|
||||
|
|
@ -306,7 +306,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "Listed on 3 RBLs",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 3,
|
||||
},
|
||||
|
|
@ -314,7 +314,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "Listed on 4+ RBLs",
|
||||
results: &DNSListResults{
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 4,
|
||||
},
|
||||
|
|
@ -326,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, _ := checker.CalculateScore(tt.results)
|
||||
score, _ := checker.CalculateRBLScore(tt.results)
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
||||
}
|
||||
|
|
@ -335,7 +335,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetUniqueListedIPs(t *testing.T) {
|
||||
results := &DNSListResults{
|
||||
results := &RBLResults{
|
||||
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 := &DNSListResults{
|
||||
results := &RBLResults{
|
||||
Checks: map[string][]api.BlacklistCheck{
|
||||
"198.51.100.1": {
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
|
|
@ -402,7 +402,7 @@ func TestGetRBLsForIP(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rbls := checker.GetListsForIP(results, tt.ip)
|
||||
rbls := checker.GetRBLsForIP(results, tt.ip)
|
||||
|
||||
if len(rbls) != len(tt.expectedRBLs) {
|
||||
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))
|
||||
|
|
|
|||
|
|
@ -35,29 +35,24 @@ type ReportGenerator struct {
|
|||
spamAnalyzer *SpamAssassinAnalyzer
|
||||
rspamdAnalyzer *RspamdAnalyzer
|
||||
dnsAnalyzer *DNSAnalyzer
|
||||
rblChecker *DNSListChecker
|
||||
dnswlChecker *DNSListChecker
|
||||
rblChecker *RBLChecker
|
||||
contentAnalyzer *ContentAnalyzer
|
||||
headerAnalyzer *HeaderAnalyzer
|
||||
}
|
||||
|
||||
// NewReportGenerator creates a new report generator
|
||||
func NewReportGenerator(
|
||||
receiverHostname string,
|
||||
dnsTimeout time.Duration,
|
||||
httpTimeout time.Duration,
|
||||
rbls []string,
|
||||
dnswls []string,
|
||||
checkAllIPs bool,
|
||||
rspamdAPIURL string,
|
||||
) *ReportGenerator {
|
||||
return &ReportGenerator{
|
||||
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
|
||||
authAnalyzer: NewAuthenticationAnalyzer(),
|
||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||
rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
|
||||
rspamdAnalyzer: NewRspamdAnalyzer(),
|
||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
||||
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
|
||||
contentAnalyzer: NewContentAnalyzer(httpTimeout),
|
||||
headerAnalyzer: NewHeaderAnalyzer(),
|
||||
}
|
||||
|
|
@ -70,8 +65,7 @@ type AnalysisResults struct {
|
|||
Content *ContentResults
|
||||
DNS *api.DNSResults
|
||||
Headers *api.HeaderAnalysis
|
||||
RBL *DNSListResults
|
||||
DNSWL *DNSListResults
|
||||
RBL *RBLResults
|
||||
SpamAssassin *api.SpamAssassinResult
|
||||
Rspamd *api.RspamdResult
|
||||
}
|
||||
|
|
@ -85,9 +79,8 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
|||
// Run all analyzers
|
||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
|
||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
|
||||
results.RBL = r.rblChecker.CheckEmail(email)
|
||||
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
|
||||
results.Content = r.contentAnalyzer.AnalyzeContent(email)
|
||||
|
|
@ -141,10 +134,8 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
|||
|
||||
blacklistScore := 0
|
||||
var blacklistGrade string
|
||||
var whitelistGrade string
|
||||
if results.RBL != nil {
|
||||
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false)
|
||||
_, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true)
|
||||
blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL)
|
||||
}
|
||||
|
||||
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
||||
|
|
@ -175,7 +166,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
|||
AuthenticationScore: authScore,
|
||||
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
|
||||
BlacklistScore: blacklistScore,
|
||||
BlacklistGrade: api.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
|
||||
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
|
||||
ContentScore: contentScore,
|
||||
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
|
||||
HeaderScore: headerScore,
|
||||
|
|
@ -206,11 +197,6 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
|||
report.Blacklists = &results.RBL.Checks
|
||||
}
|
||||
|
||||
// Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only)
|
||||
if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 {
|
||||
report.Whitelists = &results.DNSWL.Checks
|
||||
}
|
||||
|
||||
// Add SpamAssassin result with individual deliverability score
|
||||
if results.SpamAssassin != nil {
|
||||
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import (
|
|||
)
|
||||
|
||||
func TestNewReportGenerator(t *testing.T) {
|
||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||
if gen == nil {
|
||||
t.Fatal("Expected report generator, got nil")
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAnalyzeEmail(t *testing.T) {
|
||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||
|
||||
email := createTestEmail()
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGenerateReport(t *testing.T) {
|
||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createTestEmail()
|
||||
|
|
@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createTestEmailWithSpamAssassin()
|
||||
|
|
@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGenerateRawEmail(t *testing.T) {
|
||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
# 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.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -37,13 +37,11 @@ const (
|
|||
)
|
||||
|
||||
// RspamdAnalyzer analyzes rspamd results from email headers
|
||||
type RspamdAnalyzer struct {
|
||||
symbols map[string]string
|
||||
}
|
||||
type RspamdAnalyzer struct{}
|
||||
|
||||
// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions
|
||||
func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer {
|
||||
return &RspamdAnalyzer{symbols: symbols}
|
||||
// NewRspamdAnalyzer creates a new rspamd analyzer
|
||||
func NewRspamdAnalyzer() *RspamdAnalyzer {
|
||||
return &RspamdAnalyzer{}
|
||||
}
|
||||
|
||||
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
||||
|
|
@ -53,22 +51,13 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report
|
||||
_, hasSpamdResult := headers["X-Spamd-Result"]
|
||||
_, hasRspamdScore := headers["X-Rspamd-Score"]
|
||||
if !hasSpamdResult && !hasRspamdScore {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.RspamdResult{
|
||||
Symbols: make(map[string]api.SpamTestDetail),
|
||||
Symbols: make(map[string]api.RspamdSymbol),
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
|
@ -85,16 +74,6 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
|||
result.Server = &server
|
||||
}
|
||||
|
||||
// Populate symbol descriptions from the lookup map
|
||||
if a.symbols != nil {
|
||||
for name, sym := range result.Symbols {
|
||||
if desc, ok := a.symbols[name]; ok {
|
||||
sym.Description = &desc
|
||||
result.Symbols[name] = sym
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derive IsSpam from score vs reject threshold.
|
||||
if result.Threshold > 0 {
|
||||
result.IsSpam = result.Score >= result.Threshold
|
||||
|
|
@ -132,16 +111,15 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul
|
|||
}
|
||||
|
||||
// Parse symbols: SYMBOL(score)[params]
|
||||
// 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*)\)(?:\[(.*)\])?`)
|
||||
// Each symbol entry is separated by ";"
|
||||
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[([^\]]*)\])?`)
|
||||
for _, part := range strings.Split(header, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
matches := symbolRe.FindStringSubmatch(part)
|
||||
if len(matches) > 2 {
|
||||
name := matches[1]
|
||||
score, _ := strconv.ParseFloat(matches[2], 64)
|
||||
sym := api.SpamTestDetail{
|
||||
sym := api.RspamdSymbol{
|
||||
Name: name,
|
||||
Score: float32(score),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,105 +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 (
|
||||
_ "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)
|
||||
}
|
||||
|
|
@ -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(nil)
|
||||
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(nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := &api.RspamdResult{
|
||||
Symbols: make(map[string]api.SpamTestDetail),
|
||||
}
|
||||
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(nil)
|
||||
|
||||
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(nil)
|
||||
|
||||
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(nil)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,8 +73,6 @@ func ScoreToReportGrade(score int) api.ReportGrade {
|
|||
// gradeRank returns a numeric rank for a grade (lower = worse)
|
||||
func gradeRank(grade string) int {
|
||||
switch grade {
|
||||
case "A++":
|
||||
return 7
|
||||
case "A+":
|
||||
return 6
|
||||
case "A":
|
||||
|
|
|
|||
|
|
@ -45,14 +45,6 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
|
|||
return nil
|
||||
}
|
||||
|
||||
// Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report
|
||||
_, hasStatus := headers["X-Spam-Status"]
|
||||
_, hasScore := headers["X-Spam-Score"]
|
||||
_, hasFlag := headers["X-Spam-Flag"]
|
||||
if !hasStatus && !hasScore && !hasFlag {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &api.SpamAssassinResult{
|
||||
TestDetails: make(map[string]api.SpamTestDetail),
|
||||
}
|
||||
|
|
|
|||
2184
web/package-lock.json
generated
2184
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,13 +17,13 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.0",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@hey-api/openapi-ts": "0.86.10",
|
||||
"@sveltejs/adapter-static": "^3.0.9",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.12.4",
|
||||
"globals": "^17.0.0",
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
"svelte-check": "^4.3.2",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^8.0.0",
|
||||
"vite": "^7.1.10",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -13,19 +13,12 @@
|
|||
|
||||
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
|
||||
|
||||
let allRequiredMissing = $derived(
|
||||
!authentication.spf &&
|
||||
(!authentication.dkim || authentication.dkim.length === 0) &&
|
||||
!authentication.dmarc,
|
||||
);
|
||||
|
||||
function getAuthResultClass(result: string, noneIsFail: boolean): string {
|
||||
switch (result) {
|
||||
case "pass":
|
||||
case "domain_pass":
|
||||
case "orgdomain_pass":
|
||||
return "text-success";
|
||||
case "permerror":
|
||||
case "error":
|
||||
case "fail":
|
||||
case "missing":
|
||||
|
|
@ -58,7 +51,6 @@
|
|||
case "neutral":
|
||||
case "invalid":
|
||||
case "null":
|
||||
case "permerror":
|
||||
case "error":
|
||||
case "null_smtp":
|
||||
case "null_header":
|
||||
|
|
@ -103,28 +95,6 @@
|
|||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
{#if allRequiredMissing}
|
||||
<div class="card-body border-bottom">
|
||||
<div class="alert alert-warning mb-0">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>No authentication results found.</strong>
|
||||
<p class="mb-0 mt-1">
|
||||
This usually means either:
|
||||
</p>
|
||||
<ul class="mb-0 mt-1">
|
||||
<li>
|
||||
The receiving mail server is not configured to verify email authentication
|
||||
(no <code>Authentication-Results</code> header was found in the message).
|
||||
</li>
|
||||
<li>
|
||||
The <code>Authentication-Results</code> header exists but the receiver
|
||||
hostname does not match the configured
|
||||
<code>--receiver-hostname</code> value.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="list-group list-group-flush">
|
||||
<!-- IPREV -->
|
||||
{#if authentication.iprev}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
<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 { theme } from "$lib/stores/theme";
|
||||
import EmailPathCard from "./EmailPathCard.svelte";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
|
||||
interface Props {
|
||||
blacklists: Record<string, BlacklistCheck[]>;
|
||||
blacklistGrade?: string;
|
||||
blacklistScore?: number;
|
||||
receivedChain?: ReceivedHop[];
|
||||
}
|
||||
|
||||
let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
|
||||
let { blacklists, blacklistGrade, blacklistScore, receivedChain }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm" id="rbl-details">
|
||||
<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>
|
||||
<i class="bi bi-shield-exclamation me-2"></i>
|
||||
Blacklist Checks
|
||||
|
|
@ -33,7 +35,11 @@
|
|||
</h4>
|
||||
</div>
|
||||
<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]}
|
||||
<div class="col mb-3">
|
||||
<h5 class="text-muted">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import type { ReceivedHop } from "$lib/api/types.gen";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
|
||||
interface Props {
|
||||
receivedChain: ReceivedHop[];
|
||||
|
|
@ -10,18 +9,9 @@
|
|||
</script>
|
||||
|
||||
{#if receivedChain && receivedChain.length > 0}
|
||||
<div class="card shadow-sm" id="email-path">
|
||||
<div
|
||||
class="card-header"
|
||||
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">
|
||||
<div class="mb-3" id="email-path">
|
||||
<h5>Email Path (Received Chain)</h5>
|
||||
<div class="list-group">
|
||||
{#each receivedChain as hop, i}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
|
|
@ -40,7 +30,7 @@
|
|||
: "-"}
|
||||
</small>
|
||||
</div>
|
||||
{#if hop.with || hop.id || hop.from}
|
||||
{#if hop.with || hop.id}
|
||||
<p class="mb-1 small d-flex gap-3">
|
||||
{#if hop.with}
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -21,11 +21,6 @@
|
|||
);
|
||||
|
||||
const hasForwardRecords = $derived(ptrForwardRecords && ptrForwardRecords.length > 0);
|
||||
|
||||
let showDifferent = $state(false);
|
||||
const differentCount = $derived(
|
||||
ptrForwardRecords ? ptrForwardRecords.filter((ip) => ip !== senderIp).length : 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if ptrRecords && ptrRecords.length > 0}
|
||||
|
|
@ -68,31 +63,15 @@
|
|||
<div class="mb-2">
|
||||
<strong>Forward Resolution (A/AAAA):</strong>
|
||||
{#each ptrForwardRecords as ip}
|
||||
{#if ip === senderIp || !fcrDnsIsValid || showDifferent}
|
||||
<div class="d-flex gap-2 align-items-center mt-1">
|
||||
{#if senderIp && ip === senderIp}
|
||||
<span class="badge bg-success">Match</span>
|
||||
{:else}
|
||||
<span class="badge bg-secondary">Different</span>
|
||||
{/if}
|
||||
<code>{ip}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if fcrDnsIsValid && differentCount > 0}
|
||||
<div class="mt-1">
|
||||
<button
|
||||
class="btn btn-link btn-sm p-0 text-muted"
|
||||
onclick={() => (showDifferent = !showDifferent)}
|
||||
>
|
||||
{#if showDifferent}
|
||||
Hide other IPs
|
||||
{:else}
|
||||
Show {differentCount} other IP{differentCount > 1 ? 's' : ''} (not the sender)
|
||||
{/if}
|
||||
</button>
|
||||
<div class="d-flex gap-2 align-items-center mt-1">
|
||||
{#if senderIp && ip === senderIp}
|
||||
<span class="badge bg-success">Match</span>
|
||||
{:else}
|
||||
<span class="badge bg-warning">Different</span>
|
||||
{/if}
|
||||
<code>{ip}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if fcrDnsIsValid}
|
||||
<div class="alert alert-success mb-0 mt-2">
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
|
||||
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)
|
||||
|
|
@ -30,7 +31,7 @@
|
|||
<div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}">
|
||||
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<i class="bi bi-bug me-2"></i>
|
||||
<i class="bi bi-shield-exclamation me-2"></i>
|
||||
rspamd Analysis
|
||||
</span>
|
||||
<span>
|
||||
|
|
@ -75,7 +76,7 @@
|
|||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th class="text-end">Score</th>
|
||||
<th>Description</th>
|
||||
<th>Parameters</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -87,14 +88,7 @@
|
|||
? "table-success"
|
||||
: ""}
|
||||
>
|
||||
<td>
|
||||
<span class="font-monospace">{symbolName}</span>
|
||||
{#if symbol.params}
|
||||
<small class="d-block text-muted">
|
||||
{symbol.params}
|
||||
</small>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="font-monospace">{symbolName}</td>
|
||||
<td class="text-end">
|
||||
<span
|
||||
class={symbol.score > 0
|
||||
|
|
@ -106,7 +100,7 @@
|
|||
{symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small text-muted">{symbol.description ?? ""}</td>
|
||||
<td class="small text-muted">{symbol.params ?? ""}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
|
@ -114,32 +108,10 @@
|
|||
</div>
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
<style>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details summary {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
details summary:hover {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* Darker table colors in dark mode */
|
||||
:global([data-bs-theme="dark"]) .table-warning {
|
||||
--bs-table-bg: rgba(255, 193, 7, 0.2);
|
||||
|
|
|
|||
|
|
@ -25,32 +25,16 @@
|
|||
|
||||
// Email sender information
|
||||
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
||||
const hasDkim =
|
||||
report.dns_results?.dkim_records && report.dns_results?.dkim_records?.length > 0;
|
||||
const dkimPassed =
|
||||
report.authentication?.dkim &&
|
||||
report.authentication?.dkim.length > 0 &&
|
||||
report.authentication?.dkim?.some((d) => d.result === "pass");
|
||||
const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0;
|
||||
const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass");
|
||||
|
||||
segments.push({ text: "Received a " });
|
||||
segments.push({
|
||||
text: hasDkim ? "DKIM-signed" : "non-DKIM-signed",
|
||||
highlight: {
|
||||
color: hasDkim ? (dkimPassed ? "good" : "warning") : "danger",
|
||||
bold: true,
|
||||
},
|
||||
link: hasDkim && dkimPassed ? "#authentication-dkim" : "#dns-details",
|
||||
text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed",
|
||||
highlight: { color: dkimPassed ? "good" : "danger", bold: true },
|
||||
link: "#authentication-dkim",
|
||||
});
|
||||
segments.push({ text: " email" });
|
||||
if (hasDkim && !dkimPassed) {
|
||||
segments.push({ text: " with " });
|
||||
segments.push({
|
||||
text: "an invalid signature",
|
||||
highlight: { color: "danger", bold: true },
|
||||
link: "#authentication-dkim",
|
||||
});
|
||||
}
|
||||
segments.push({ text: " from " });
|
||||
segments.push({ text: " email from " });
|
||||
segments.push({
|
||||
text: mailFrom,
|
||||
highlight: { emphasis: true },
|
||||
|
|
@ -129,7 +113,7 @@
|
|||
} else if (spfResult === "temperror" || spfResult === "permerror") {
|
||||
segments.push({
|
||||
text: "encountered an error",
|
||||
highlight: { color: "danger", bold: true },
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#authentication-spf",
|
||||
});
|
||||
segments.push({ text: ", check your SPF record configuration" });
|
||||
|
|
@ -347,7 +331,7 @@
|
|||
highlight: { color: "good", bold: true },
|
||||
link: "#dns-bimi",
|
||||
});
|
||||
if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) {
|
||||
if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) {
|
||||
segments.push({ text: " declined to participate" });
|
||||
} else if (bimiResult?.result === "fail") {
|
||||
segments.push({ text: " but " });
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { BlacklistCheck } from "$lib/api/types.gen";
|
||||
import { theme } from "$lib/stores/theme";
|
||||
|
||||
interface Props {
|
||||
whitelists: Record<string, BlacklistCheck[]>;
|
||||
}
|
||||
|
||||
let { whitelists }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm" id="dnswl-details">
|
||||
<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">
|
||||
<span>
|
||||
<i class="bi bi-shield-check me-2"></i>
|
||||
Whitelist Checks
|
||||
</span>
|
||||
<span class="badge bg-info text-white">Informational</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
DNS whitelists identify trusted senders. Being listed here is a positive signal, but has
|
||||
no impact on the overall score.
|
||||
</p>
|
||||
|
||||
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
||||
{#each Object.entries(whitelists) as [ip, checks]}
|
||||
<div class="col mb-3">
|
||||
<h5 class="text-muted">
|
||||
<i class="bi bi-hdd-network me-1"></i>
|
||||
{ip}
|
||||
</h5>
|
||||
<table class="table table-sm table-striped table-hover mb-0">
|
||||
<tbody>
|
||||
{#each checks as check}
|
||||
<tr>
|
||||
<td title={check.response || "-"}>
|
||||
<span
|
||||
class="badge"
|
||||
class:bg-success={check.listed}
|
||||
class:bg-dark={check.error}
|
||||
class:bg-secondary={!check.listed && !check.error}
|
||||
>
|
||||
{check.error
|
||||
? "Error"
|
||||
: check.listed
|
||||
? "Listed"
|
||||
: "Not listed"}
|
||||
</span>
|
||||
</td>
|
||||
<td><code>{check.rbl}</code></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -24,4 +24,3 @@ export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
|||
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
|
||||
export { default as SummaryCard } from "./SummaryCard.svelte";
|
||||
export { default as TinySurvey } from "./TinySurvey.svelte";
|
||||
export { default as WhitelistCard } from "./WhitelistCard.svelte";
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ interface AppConfig {
|
|||
report_retention?: number;
|
||||
survey_url?: string;
|
||||
custom_logo_url?: string;
|
||||
rbls?: string[];
|
||||
}
|
||||
|
||||
const defaultConfig: AppConfig = {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const getInitialTheme = () => {
|
|||
if (!browser) return "light";
|
||||
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
if (stored) return stored;
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { onMount } from "svelte";
|
||||
import { checkBlacklist } from "$lib/api";
|
||||
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";
|
||||
|
||||
let ip = $derived($page.params.ip);
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
});
|
||||
|
||||
if (response.response.ok) {
|
||||
result = response.data ?? null;
|
||||
result = response.data;
|
||||
} else if (response.error) {
|
||||
error = response.error.message || "Failed to check IP address";
|
||||
}
|
||||
|
|
@ -122,8 +122,8 @@
|
|||
>
|
||||
<p class="mb-0 mt-1 small">
|
||||
This IP address is listed on {result.listed_count} of
|
||||
{result.blacklists.length} checked blacklist{result
|
||||
.blacklists.length > 1
|
||||
{result.checks.length} checked blacklist{result
|
||||
.checks.length > 1
|
||||
? "s"
|
||||
: ""}.
|
||||
</p>
|
||||
|
|
@ -150,23 +150,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Blacklist Results Card -->
|
||||
<div class="col col-lg-6">
|
||||
<BlacklistCard
|
||||
blacklists={{ [result.ip]: result.blacklists }}
|
||||
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>
|
||||
<!-- Blacklist Results Card -->
|
||||
<BlacklistCard
|
||||
blacklists={{ [result.ip]: result.checks }}
|
||||
blacklistScore={result.score}
|
||||
blacklistGrade={result.grade}
|
||||
/>
|
||||
|
||||
<!-- Information Card -->
|
||||
<div class="card shadow-sm mt-4">
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@
|
|||
<div class="d-flex justify-content-end me-lg-5 mt-3">
|
||||
<TinySurvey
|
||||
class="bg-primary-subtle rounded-4 p-3 text-center"
|
||||
source={"domain-" + result.domain}
|
||||
source={"rbl-" + result.ip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@
|
|||
import { onDestroy } from "svelte";
|
||||
|
||||
import { getReport, getTest, reanalyzeReport } from "$lib/api";
|
||||
import type { BlacklistCheck, Report, Test } from "$lib/api/types.gen";
|
||||
import type { Report, Test } from "$lib/api/types.gen";
|
||||
import {
|
||||
AuthenticationCard,
|
||||
BlacklistCard,
|
||||
ContentAnalysisCard,
|
||||
DnsRecordsCard,
|
||||
EmailPathCard,
|
||||
ErrorDisplay,
|
||||
HeaderAnalysisCard,
|
||||
PendingState,
|
||||
|
|
@ -18,11 +17,8 @@
|
|||
SpamAssassinCard,
|
||||
SummaryCard,
|
||||
TinySurvey,
|
||||
WhitelistCard,
|
||||
} from "$lib/components";
|
||||
|
||||
type BlacklistRecords = Record<string, BlacklistCheck[]>;
|
||||
|
||||
let testId = $derived(page.params.test);
|
||||
let test = $state<Test | null>(null);
|
||||
let report = $state<Report | null>(null);
|
||||
|
|
@ -295,15 +291,6 @@
|
|||
</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 -->
|
||||
{#if report.dns_results}
|
||||
<div class="row mb-4" id="dns">
|
||||
|
|
@ -334,45 +321,17 @@
|
|||
{/if}
|
||||
|
||||
<!-- Blacklist Checks -->
|
||||
{#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)}
|
||||
<BlacklistCard
|
||||
{blacklists}
|
||||
blacklistGrade={report.summary?.blacklist_grade}
|
||||
blacklistScore={report.summary?.blacklist_score}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<!-- Whitelist Checks -->
|
||||
{#snippet whitelistChecks(whitelists: BlacklistRecords)}
|
||||
<WhitelistCard {whitelists} />
|
||||
{/snippet}
|
||||
|
||||
<!-- Blacklist & Whitelist Checks -->
|
||||
{#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1}
|
||||
<div class="row mb-4">
|
||||
<div class="col-6" id="blacklist">
|
||||
{@render blacklistChecks(report.blacklists, report)}
|
||||
</div>
|
||||
<div class="col-6" id="whitelist">
|
||||
{@render whitelistChecks(report.whitelists)}
|
||||
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
|
||||
<div class="row mb-4" id="blacklist">
|
||||
<div class="col-12">
|
||||
<BlacklistCard
|
||||
blacklists={report.blacklists}
|
||||
blacklistGrade={report.summary?.blacklist_grade}
|
||||
blacklistScore={report.summary?.blacklist_score}
|
||||
receivedChain={report.header_analysis?.received_chain}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
|
||||
<div class="row mb-4" id="blacklist">
|
||||
<div class="col-12">
|
||||
{@render blacklistChecks(report.blacklists, report)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if report.whitelists && Object.keys(report.whitelists).length > 0}
|
||||
<div class="row mb-4" id="whitelist">
|
||||
<div class="col-12">
|
||||
{@render whitelistChecks(report.whitelists)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Header Analysis -->
|
||||
|
|
@ -393,12 +352,12 @@
|
|||
{#if report.spamassassin || report.rspamd}
|
||||
<div class="row mb-4" id="spam">
|
||||
{#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} />
|
||||
</div>
|
||||
{/if}
|
||||
{#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} />
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue