Compare commits

..

No commits in common. "master" and "f/rspamd" have entirely different histories.

84 changed files with 2866 additions and 11979 deletions

2
.gitignore vendored
View file

@ -26,5 +26,5 @@ logs/
*.sqlite3
# OpenAPI generated files
internal/api/models.gen.go
internal/api/server.gen.go
internal/model/types.gen.go

View file

@ -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"]

View file

@ -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
@ -279,33 +262,6 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
**Note:** In production, emails are delivered via LMTP (see integration instructions above).
## Use with happyDomain
happyDeliver can be driven by [happyDomain](https://happydomain.org) through
the [`checker-happydeliver`](https://git.nemunai.re/happyDomain/checker-happydeliver)
plugin, so the deliverability of a domain you manage is monitored alongside
its DNS and inbound SMTP posture.
How it works:
1. Attach the **Outbound deliverability** checker to the mail service of a zone
in happyDomain. Point it at a happyDeliver instance via `happydeliver_url`;
operators can configure a default instance globally.
2. On each run, the checker calls `POST /api/test` to allocate a fresh
recipient address, prompts the user (or an automated sender) to mail it from
the tested domain, then polls `GET /api/test/{id}` until the report is
ready.
3. The structured report from `GET /api/report/{id}` is translated into
happyDomain rule states: CRIT/WARN/INFO on SPF, DKIM, DMARC, alignment, spam
score, blacklists and headers, plus an overall score threshold
(`min_score`/`warn_score`).
4. Runs repeat on a configurable interval so a regression in deliverability (a
new RBL listing, a DKIM key rotation gone wrong, a broken SPF include, ...)
surfaces as a domain-level alert in happyDomain.
See the [`checker-happydeliver` repository](https://git.nemunai.re/happyDomain/checker-happydeliver)
for build instructions and the full list of run options.
## Scoring System
The deliverability score is calculated from A to F based on:

View file

@ -1,9 +1,5 @@
package: model
package: api
generate:
models: true
embedded-spec: true
output: internal/model/types.gen.go
output-options:
skip-prune: true
import-mapping:
./schemas.yaml: "-"
embedded-spec: false
output: internal/api/models.gen.go

View file

@ -1,8 +1,5 @@
package: api
generate:
gin-server: true
models: true
embedded-spec: true
output: internal/api/server.gen.go
import-mapping:
./schemas.yaml: git.happydns.org/happyDeliver/internal/model

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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:**

View file

@ -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

View file

@ -21,5 +21,5 @@
package main
//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml
//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml
//go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml

22
go.mod
View file

@ -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
View file

@ -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=

View file

@ -31,7 +31,6 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/version"
@ -41,8 +40,8 @@ import (
// This interface breaks the circular dependency with pkg/analyzer
type EmailAnalyzer interface {
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
AnalyzeDomain(domain string) (dnsResults *model.DNSResults, score int, grade string)
CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.BlacklistCheck, listedCount int, score int, grade string, err error)
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
CheckBlacklistIP(ip string) (checks []BlacklistCheck, listedCount int, score int, grade string, err error)
}
// APIHandler implements the ServerInterface for handling API requests
@ -80,11 +79,11 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
)
// Return response
c.JSON(http.StatusCreated, model.TestResponse{
c.JSON(http.StatusCreated, TestResponse{
Id: base32ID,
Email: openapi_types.Email(email),
Status: model.TestResponseStatusPending,
Message: utils.PtrTo("Send your test email to the given address"),
Status: TestResponseStatusPending,
Message: stringPtr("Send your test email to the given address"),
})
}
@ -94,10 +93,10 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error{
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
@ -105,20 +104,20 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
// Check if a report exists for this test ID
reportExists, err := h.storage.ReportExists(testUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to check test status",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
// Determine status based on report existence
var apiStatus model.TestStatus
var apiStatus TestStatus
if reportExists {
apiStatus = model.TestStatusAnalyzed
apiStatus = TestStatusAnalyzed
} else {
apiStatus = model.TestStatusPending
apiStatus = TestStatusPending
}
// Generate test email address using Base32-encoded UUID
@ -128,7 +127,7 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
h.config.Email.Domain,
)
c.JSON(http.StatusOK, model.Test{
c.JSON(http.StatusOK, Test{
Id: id,
Email: openapi_types.Email(email),
Status: apiStatus,
@ -141,10 +140,10 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error{
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
@ -152,16 +151,16 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
reportJSON, _, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, model.Error{
c.JSON(http.StatusNotFound, Error{
Error: "not_found",
Message: "Report not found",
})
return
}
c.JSON(http.StatusInternalServerError, model.Error{
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve report",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
@ -176,10 +175,10 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error{
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
@ -187,16 +186,16 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
_, rawEmail, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, model.Error{
c.JSON(http.StatusNotFound, Error{
Error: "not_found",
Message: "Email not found",
})
return
}
c.JSON(http.StatusInternalServerError, model.Error{
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve raw email",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
@ -210,10 +209,10 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error{
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
@ -222,16 +221,16 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
_, rawEmail, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, model.Error{
c.JSON(http.StatusNotFound, Error{
Error: "not_found",
Message: "Email not found",
})
return
}
c.JSON(http.StatusInternalServerError, model.Error{
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve email",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
@ -239,20 +238,20 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
// Re-analyze the email using the current analyzer
reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{
c.JSON(http.StatusInternalServerError, Error{
Error: "analysis_error",
Message: "Failed to re-analyze email",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
// Update the report in storage
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
c.JSON(http.StatusInternalServerError, model.Error{
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to update report",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
@ -268,24 +267,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
uptime := int(time.Since(h.startTime).Seconds())
// Check database connectivity by trying to check if a report exists
dbStatus := model.StatusComponentsDatabaseUp
dbStatus := StatusComponentsDatabaseUp
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
dbStatus = model.StatusComponentsDatabaseDown
dbStatus = StatusComponentsDatabaseDown
}
// Determine overall status
overallStatus := model.Healthy
if dbStatus == model.StatusComponentsDatabaseDown {
overallStatus = model.Unhealthy
overallStatus := Healthy
if dbStatus == StatusComponentsDatabaseDown {
overallStatus = Unhealthy
}
mtaStatus := model.StatusComponentsMtaUp
c.JSON(http.StatusOK, model.Status{
mtaStatus := StatusComponentsMtaUp
c.JSON(http.StatusOK, Status{
Status: overallStatus,
Version: version.Version,
Components: &struct {
Database *model.StatusComponentsDatabase `json:"database,omitempty"`
Mta *model.StatusComponentsMta `json:"mta,omitempty"`
Database *StatusComponentsDatabase `json:"database,omitempty"`
Mta *StatusComponentsMta `json:"mta,omitempty"`
}{
Database: &dbStatus,
Mta: &mtaStatus,
@ -297,14 +296,14 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
// TestDomain performs synchronous domain analysis
// (POST /domain)
func (h *APIHandler) TestDomain(c *gin.Context) {
var request model.DomainTestRequest
var request DomainTestRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, model.Error{
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
@ -313,28 +312,28 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
// Convert grade string to DomainTestResponseGrade enum
var responseGrade model.DomainTestResponseGrade
var responseGrade DomainTestResponseGrade
switch grade {
case "A+":
responseGrade = model.DomainTestResponseGradeA
responseGrade = DomainTestResponseGradeA
case "A":
responseGrade = model.DomainTestResponseGradeA1
responseGrade = DomainTestResponseGradeA1
case "B":
responseGrade = model.DomainTestResponseGradeB
responseGrade = DomainTestResponseGradeB
case "C":
responseGrade = model.DomainTestResponseGradeC
responseGrade = DomainTestResponseGradeC
case "D":
responseGrade = model.DomainTestResponseGradeD
responseGrade = DomainTestResponseGradeD
case "E":
responseGrade = model.DomainTestResponseGradeE
responseGrade = DomainTestResponseGradeE
case "F":
responseGrade = model.DomainTestResponseGradeF
responseGrade = DomainTestResponseGradeF
default:
responseGrade = model.DomainTestResponseGradeF
responseGrade = DomainTestResponseGradeF
}
// Build response
response := model.DomainTestResponse{
response := DomainTestResponse{
Domain: request.Domain,
Score: score,
Grade: responseGrade,
@ -347,79 +346,37 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
// CheckBlacklist checks an IP address against DNS blacklists
// (POST /blacklist)
func (h *APIHandler) CheckBlacklist(c *gin.Context) {
var request model.BlacklistCheckRequest
var request BlacklistCheckRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, model.Error{
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
// 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, model.Error{
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_ip",
Message: "Invalid IP address",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
// Build response
response := model.BlacklistCheckResponse{
response := BlacklistCheckResponse{
Ip: request.Ip,
Blacklists: checks,
Whitelists: &whitelists,
Checks: checks,
ListedCount: listedCount,
Score: score,
Grade: model.BlacklistCheckResponseGrade(grade),
Grade: BlacklistCheckResponseGrade(grade),
}
c.JSON(http.StatusOK, response)
}
// ListTests returns a paginated list of test summaries
// (GET /tests)
func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {
if h.config.DisableTestList {
c.JSON(http.StatusForbidden, model.Error{
Error: "feature_disabled",
Message: "Test listing is disabled on this instance",
})
return
}
offset := 0
limit := 20
if params.Offset != nil {
offset = *params.Offset
}
if params.Limit != nil {
limit = *params.Limit
if limit > 100 {
limit = 100
}
}
tests, total, err := h.storage.ListReportSummaries(offset, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to list tests",
Details: utils.PtrTo(err.Error()),
})
return
}
c.JSON(http.StatusOK, model.TestListResponse{
Tests: tests,
Total: int(total),
Offset: offset,
Limit: limit,
})
}

View file

@ -1,5 +1,5 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2026 happyDomain
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
@ -19,7 +19,11 @@
// 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 utils
package api
func stringPtr(s string) *string {
return &s
}
// PtrTo returns a pointer to the provided value
func PtrTo[T any](v T) *T {

View file

@ -34,17 +34,14 @@ 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")
flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI")
flag.BoolVar(&o.DisableTestList, "disable-test-list", o.DisableTestList, "Disable the public test listing endpoint")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -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
@ -50,7 +45,6 @@ type Config struct {
RateLimit uint // API rate limit (requests per second per IP)
SurveyURL url.URL // URL for user feedback survey
CustomLogoURL string // URL for custom logo image in the web UI
DisableTestList bool // Disable the public test listing endpoint
}
// DatabaseConfig contains database connection settings
@ -64,7 +58,6 @@ type EmailConfig struct {
Domain string
TestAddressPrefix string
LMTPAddr string
ReceiverHostname string
}
// AnalysisConfig contains timeout and behavior settings for email analysis
@ -72,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
@ -92,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
},
}

View file

@ -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 {

View file

@ -30,9 +30,6 @@ import (
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
var (
@ -48,7 +45,6 @@ type Storage interface {
ReportExists(testID uuid.UUID) (bool, error)
UpdateReport(testID uuid.UUID, reportJSON []byte) error
DeleteOldReports(olderThan time.Time) (int64, error)
ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error)
// Close closes the database connection
Close() error
@ -143,72 +139,6 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
return result.RowsAffected, nil
}
// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary
type reportSummaryRow struct {
TestID uuid.UUID
Score int
Grade string
FromDomain string
CreatedAt time.Time
}
// ListReportSummaries returns a paginated list of lightweight report summaries
func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) {
var total int64
if err := s.db.Model(&Report{}).Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count reports: %w", err)
}
if total == 0 {
return []model.TestSummary{}, 0, nil
}
var selectExpr string
switch s.db.Dialector.Name() {
case "postgres":
selectExpr = `test_id, ` +
`(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` +
`convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` +
`convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` +
`created_at`
case "sqlite":
selectExpr = `test_id, ` +
`json_extract(report_json, '$.score') as score, ` +
`json_extract(report_json, '$.grade') as grade, ` +
`json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` +
`created_at`
default:
return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect")
}
var rows []reportSummaryRow
err := s.db.Model(&Report{}).
Select(selectExpr).
Order("created_at DESC").
Offset(offset).
Limit(limit).
Scan(&rows).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to list report summaries: %w", err)
}
summaries := make([]model.TestSummary, 0, len(rows))
for _, r := range rows {
s := model.TestSummary{
TestId: utils.UUIDToBase32(r.TestID),
Score: r.Score,
Grade: model.TestSummaryGrade(r.Grade),
CreatedAt: r.CreatedAt,
}
if r.FromDomain != "" {
s.FromDomain = utils.PtrTo(r.FromDomain)
}
summaries = append(summaries, s)
}
return summaries, total, nil
}
// Close closes the database connection
func (s *DBStorage) Close() error {
sqlDB, err := s.db.DB()

View file

@ -28,7 +28,7 @@ import (
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
)
@ -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{
@ -59,7 +56,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
type AnalysisResult struct {
Email *EmailMessage
Results *AnalysisResults
Report *model.Report
Report *api.Report
}
// AnalyzeEmailBytes performs complete email analysis from raw bytes
@ -113,7 +110,7 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt
}
// AnalyzeDomain performs DNS analysis for a domain and returns the results
func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) {
func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) {
// Perform DNS analysis
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
@ -123,28 +120,22 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, strin
return dnsResults, score, grade
}
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.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{
Checks: map[string][]model.BlacklistCheck{ip: checks},
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
}

View file

@ -24,25 +24,23 @@ package analyzer
import (
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
// 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
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults {
results := &model.AuthenticationResults{}
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
results := &api.AuthenticationResults{}
// Parse Authentication-Results headers
authHeaders := email.GetAuthenticationResults(a.receiverHostname)
authHeaders := email.GetAuthenticationResults()
for _, header := range authHeaders {
a.parseAuthenticationResultsHeader(header, results)
}
@ -65,7 +63,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *mod
// parseAuthenticationResultsHeader parses an Authentication-Results header
// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) {
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) {
// Split by semicolon to get individual results
parts := strings.Split(header, ";")
if len(parts) < 2 {
@ -91,7 +89,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
dkimResult := a.parseDKIMResult(part)
if dkimResult != nil {
if results.Dkim == nil {
dkimList := []model.AuthResult{*dkimResult}
dkimList := []api.AuthResult{*dkimResult}
results.Dkim = &dkimList
} else {
*results.Dkim = append(*results.Dkim, *dkimResult)
@ -145,39 +143,34 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
// CalculateAuthenticationScore calculates the authentication score from auth results
// Returns a score from 0-100 where higher is better
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) {
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) {
if results == nil {
return 0, ""
}
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

View file

@ -27,8 +27,7 @@ import (
"slices"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// textprotoCanonical converts a header name to canonical form
@ -53,24 +52,24 @@ func pluralize(count int) string {
// parseARCResult parses ARC result from Authentication-Results
// Example: arc=pass
func (a *AuthenticationAnalyzer) parseARCResult(part string) *model.ARCResult {
result := &model.ARCResult{}
func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
result := &api.ARCResult{}
// Extract result (pass, fail, none)
re := regexp.MustCompile(`arc=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.ARCResultResult(resultStr)
result.Result = api.ARCResultResult(resultStr)
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "arc="))
result.Details = api.PtrTo(strings.TrimPrefix(part, "arc="))
return result
}
// parseARCHeaders parses ARC headers from email message
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARCResult {
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
// Get all ARC-related headers
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
@ -81,8 +80,8 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARC
return nil
}
result := &model.ARCResult{
Result: model.ARCResultResultNone,
result := &api.ARCResult{
Result: api.ARCResultResultNone,
}
// Count the ARC chain length (number of sets)
@ -95,15 +94,15 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARC
// Determine overall result
if chainLength == 0 {
result.Result = model.ARCResultResultNone
result.Result = api.ARCResultResultNone
details := "No ARC chain present"
result.Details = &details
} else if !chainValid {
result.Result = model.ARCResultResultFail
result.Result = api.ARCResultResultFail
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
result.Details = &details
} else {
result.Result = model.ARCResultResultPass
result.Result = api.ARCResultResultPass
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
result.Details = &details
}
@ -112,7 +111,7 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARC
}
// enhanceARCResult enhances an existing ARC result with chain information
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) {
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
if arcResult == nil {
return
}

View file

@ -24,33 +24,33 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseARCResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.ARCResultResult
expectedResult api.ARCResultResult
}{
{
name: "ARC pass",
part: "arc=pass",
expectedResult: model.ARCResultResultPass,
expectedResult: api.ARCResultResultPass,
},
{
name: "ARC fail",
part: "arc=fail",
expectedResult: model.ARCResultResultFail,
expectedResult: api.ARCResultResultFail,
},
{
name: "ARC none",
part: "arc=none",
expectedResult: model.ARCResultResultNone,
expectedResult: api.ARCResultResultNone,
},
}
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) {

View file

@ -25,20 +25,19 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// parseBIMIResult parses BIMI result from Authentication-Results
// Example: bimi=pass header.d=example.com header.selector=default
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult {
result := &model.AuthResult{}
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`bimi=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.AuthResultResult(resultStr)
result.Result = api.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
@ -55,17 +54,17 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult
result.Selector = &selector
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "bimi="))
result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi="))
return result
}
func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) {
if results.Bimi != nil {
switch results.Bimi.Result {
case model.AuthResultResultPass:
case api.AuthResultResultPass:
return 100
case model.AuthResultResultDeclined:
case api.AuthResultResultDeclined:
return 59
default: // fail
return 0

View file

@ -24,47 +24,47 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseBIMIResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedResult api.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "BIMI pass with domain and selector",
part: "bimi=pass header.d=example.com header.selector=default",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "BIMI fail",
part: "bimi=fail header.d=example.com header.selector=default",
expectedResult: model.AuthResultResultFail,
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "BIMI with short form (d= and selector=)",
part: "bimi=pass d=example.com selector=v1",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "v1",
},
{
name: "BIMI none",
part: "bimi=none header.d=example.com",
expectedResult: model.AuthResultResultNone,
expectedResult: api.AuthResultResultNone,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer("")
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -25,20 +25,19 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// parseDKIMResult parses DKIM result from Authentication-Results
// Example: dkim=pass header.d=example.com header.s=selector1
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult {
result := &model.AuthResult{}
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`dkim=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.AuthResultResult(resultStr)
result.Result = api.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
@ -55,18 +54,18 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult
result.Selector = &selector
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim="))
result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim="))
return result
}
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *model.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) {
// Expect at least one passing signature
if results.Dkim != nil && len(*results.Dkim) > 0 {
hasPass := false
hasNonPass := false
for _, dkim := range *results.Dkim {
if dkim.Result == model.AuthResultResultPass {
if dkim.Result == api.AuthResultResultPass {
hasPass = true
} else {
hasNonPass = true

View file

@ -24,41 +24,41 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedResult api.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "DKIM pass with domain and selector",
part: "dkim=pass header.d=example.com header.s=default",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "DKIM fail",
part: "dkim=fail header.d=example.com header.s=selector1",
expectedResult: model.AuthResultResultFail,
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
expectedSelector: "selector1",
},
{
name: "DKIM with short form (d= and s=)",
part: "dkim=pass d=example.com s=default",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
}
analyzer := NewAuthenticationAnalyzer("")
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -25,20 +25,19 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// parseDMARCResult parses DMARC result from Authentication-Results
// Example: dmarc=pass action=none header.from=example.com
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult {
result := &model.AuthResult{}
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`dmarc=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.AuthResultResult(resultStr)
result.Result = api.AuthResultResult(resultStr)
}
// Extract domain (header.from)
@ -48,17 +47,17 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult
result.Domain = &domain
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "dmarc="))
result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc="))
return result
}
func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) {
if results.Dmarc != nil {
switch results.Dmarc.Result {
case model.AuthResultResultPass:
case api.AuthResultResultPass:
return 100
case model.AuthResultResultNone:
case api.AuthResultResultNone:
return 33
default: // fail
return 0

View file

@ -24,31 +24,31 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseDMARCResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedResult api.AuthResultResult
expectedDomain string
}{
{
name: "DMARC pass",
part: "dmarc=pass action=none header.from=example.com",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "DMARC fail",
part: "dmarc=fail action=quarantine header.from=example.com",
expectedResult: model.AuthResultResultFail,
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer("")
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -25,20 +25,19 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// parseIPRevResult parses IP reverse lookup result from Authentication-Results
// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)
func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResult {
result := &model.IPRevResult{}
func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult {
result := &api.IPRevResult{}
// Extract result (pass, fail, temperror, permerror, none)
re := regexp.MustCompile(`iprev=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.IPRevResultResult(resultStr)
result.Result = api.IPRevResultResult(resultStr)
}
// Extract IP address (smtp.remote-ip or remote-ip)
@ -55,20 +54,20 @@ func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResul
result.Hostname = &hostname
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "iprev="))
result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev="))
return result
}
func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) {
if results.Iprev != nil {
switch results.Iprev.Result {
case model.Pass:
case api.Pass:
return 100
default: // fail, temperror, permerror
return 0
}
}
return 100
return 0
}

View file

@ -24,77 +24,76 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseIPRevResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.IPRevResultResult
expectedResult api.IPRevResultResult
expectedIP *string
expectedHostname *string
}{
{
name: "IPRev pass with IP and hostname",
part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
expectedResult: model.Pass,
expectedIP: utils.PtrTo("195.110.101.58"),
expectedHostname: utils.PtrTo("authsmtp74.register.it"),
expectedResult: api.Pass,
expectedIP: api.PtrTo("195.110.101.58"),
expectedHostname: api.PtrTo("authsmtp74.register.it"),
},
{
name: "IPRev pass without smtp prefix",
part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
expectedResult: model.Pass,
expectedIP: utils.PtrTo("192.0.2.1"),
expectedHostname: utils.PtrTo("mail.example.com"),
expectedResult: api.Pass,
expectedIP: api.PtrTo("192.0.2.1"),
expectedHostname: api.PtrTo("mail.example.com"),
},
{
name: "IPRev fail",
part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
expectedResult: model.Fail,
expectedIP: utils.PtrTo("198.51.100.42"),
expectedHostname: utils.PtrTo("unknown.host.com"),
expectedResult: api.Fail,
expectedIP: api.PtrTo("198.51.100.42"),
expectedHostname: api.PtrTo("unknown.host.com"),
},
{
name: "IPRev temperror",
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
expectedResult: model.Temperror,
expectedIP: utils.PtrTo("203.0.113.1"),
expectedResult: api.Temperror,
expectedIP: api.PtrTo("203.0.113.1"),
expectedHostname: nil,
},
{
name: "IPRev permerror",
part: "iprev=permerror smtp.remote-ip=192.0.2.100",
expectedResult: model.Permerror,
expectedIP: utils.PtrTo("192.0.2.100"),
expectedResult: api.Permerror,
expectedIP: api.PtrTo("192.0.2.100"),
expectedHostname: nil,
},
{
name: "IPRev with IPv6",
part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
expectedResult: model.Pass,
expectedIP: utils.PtrTo("2001:db8::1"),
expectedHostname: utils.PtrTo("ipv6.example.com"),
expectedResult: api.Pass,
expectedIP: api.PtrTo("2001:db8::1"),
expectedHostname: api.PtrTo("ipv6.example.com"),
},
{
name: "IPRev with subdomain hostname",
part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
expectedResult: model.Pass,
expectedIP: utils.PtrTo("192.0.2.50"),
expectedHostname: utils.PtrTo("mail.subdomain.example.com"),
expectedResult: api.Pass,
expectedIP: api.PtrTo("192.0.2.50"),
expectedHostname: api.PtrTo("mail.subdomain.example.com"),
},
{
name: "IPRev pass without parentheses",
part: "iprev=pass smtp.remote-ip=192.0.2.200",
expectedResult: model.Pass,
expectedIP: utils.PtrTo("192.0.2.200"),
expectedResult: api.Pass,
expectedIP: api.PtrTo("192.0.2.200"),
expectedHostname: nil,
},
}
analyzer := NewAuthenticationAnalyzer("")
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -143,29 +142,29 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
tests := []struct {
name string
header string
expectedIPRevResult *model.IPRevResultResult
expectedIPRevResult *api.IPRevResultResult
expectedIP *string
expectedHostname *string
}{
{
name: "IPRev pass in Authentication-Results",
header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
expectedIPRevResult: utils.PtrTo(model.Pass),
expectedIP: utils.PtrTo("195.110.101.58"),
expectedHostname: utils.PtrTo("authsmtp74.register.it"),
expectedIPRevResult: api.PtrTo(api.Pass),
expectedIP: api.PtrTo("195.110.101.58"),
expectedHostname: api.PtrTo("authsmtp74.register.it"),
},
{
name: "IPRev with other authentication methods",
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com",
expectedIPRevResult: utils.PtrTo(model.Pass),
expectedIP: utils.PtrTo("192.0.2.1"),
expectedHostname: utils.PtrTo("mail.example.com"),
expectedIPRevResult: api.PtrTo(api.Pass),
expectedIP: api.PtrTo("192.0.2.1"),
expectedHostname: api.PtrTo("mail.example.com"),
},
{
name: "IPRev fail",
header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
expectedIPRevResult: utils.PtrTo(model.Fail),
expectedIP: utils.PtrTo("198.51.100.42"),
expectedIPRevResult: api.PtrTo(api.Fail),
expectedIP: api.PtrTo("198.51.100.42"),
expectedHostname: nil,
},
{
@ -176,17 +175,17 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
{
name: "Multiple IPRev results - only first is parsed",
header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)",
expectedIPRevResult: utils.PtrTo(model.Pass),
expectedIP: utils.PtrTo("192.0.2.1"),
expectedHostname: utils.PtrTo("first.com"),
expectedIPRevResult: api.PtrTo(api.Pass),
expectedIP: api.PtrTo("192.0.2.1"),
expectedHostname: api.PtrTo("first.com"),
},
}
analyzer := NewAuthenticationAnalyzer("")
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &model.AuthenticationResults{}
results := &api.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(tt.header, results)
// Check IPRev

View file

@ -25,20 +25,19 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// parseSPFResult parses SPF result from Authentication-Results
// Example: spf=pass smtp.mailfrom=sender@example.com
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult {
result := &model.AuthResult{}
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`spf=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.AuthResultResult(resultStr)
result.Result = api.AuthResultResult(resultStr)
}
// Extract domain
@ -52,35 +51,25 @@ func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult {
}
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "spf="))
result.Details = api.PtrTo(strings.TrimPrefix(part, "spf="))
return result
}
// parseLegacySPF attempts to parse SPF from Received-SPF header
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.AuthResult {
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
receivedSPF := email.Header.Get("Received-SPF")
if receivedSPF == "" {
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 := &model.AuthResult{}
result := &api.AuthResult{}
// Extract result (first word)
parts := strings.Fields(receivedSPF)
if len(parts) > 0 {
resultStr := strings.ToLower(parts[0])
result.Result = model.AuthResultResult(resultStr)
result.Result = api.AuthResultResult(resultStr)
}
result.Details = &receivedSPF
@ -98,14 +87,14 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.Auth
return result
}
func (a *AuthenticationAnalyzer) calculateSPFScore(results *model.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) {
if results.Spf != nil {
switch results.Spf.Result {
case model.AuthResultResultPass:
case api.AuthResultResultPass:
return 100
case model.AuthResultResultNeutral, model.AuthResultResultNone:
case api.AuthResultResultNeutral, api.AuthResultResultNone:
return 50
case model.AuthResultResultSoftfail:
case api.AuthResultResultSoftfail:
return 17
default: // fail, temperror, permerror
return 0

View file

@ -24,44 +24,43 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseSPFResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedResult api.AuthResultResult
expectedDomain string
}{
{
name: "SPF pass with domain",
part: "spf=pass smtp.mailfrom=sender@example.com",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "SPF fail",
part: "spf=fail smtp.mailfrom=sender@example.com",
expectedResult: model.AuthResultResultFail,
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "SPF neutral",
part: "spf=neutral smtp.mailfrom=sender@example.com",
expectedResult: model.AuthResultResultNeutral,
expectedResult: api.AuthResultResultNeutral,
expectedDomain: "example.com",
},
{
name: "SPF softfail",
part: "spf=softfail smtp.mailfrom=sender@example.com",
expectedResult: model.AuthResultResultSoftfail,
expectedResult: api.AuthResultResultSoftfail,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer("")
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -85,7 +84,7 @@ func TestParseLegacySPF(t *testing.T) {
tests := []struct {
name string
receivedSPF string
expectedResult model.AuthResultResult
expectedResult api.AuthResultResult
expectedDomain *string
expectNil bool
}{
@ -98,8 +97,8 @@ func TestParseLegacySPF(t *testing.T) {
envelope-from="user@example.com";
helo=smtp.example.com;
client-ip=192.0.2.10`,
expectedResult: model.AuthResultResultPass,
expectedDomain: utils.PtrTo("example.com"),
expectedResult: api.AuthResultResultPass,
expectedDomain: api.PtrTo("example.com"),
},
{
name: "SPF fail with sender",
@ -110,43 +109,43 @@ func TestParseLegacySPF(t *testing.T) {
sender="sender@test.com";
helo=smtp.test.com;
client-ip=192.0.2.20`,
expectedResult: model.AuthResultResultFail,
expectedDomain: utils.PtrTo("test.com"),
expectedResult: api.AuthResultResultFail,
expectedDomain: api.PtrTo("test.com"),
},
{
name: "SPF softfail",
receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"",
expectedResult: model.AuthResultResultSoftfail,
expectedDomain: utils.PtrTo("example.org"),
expectedResult: api.AuthResultResultSoftfail,
expectedDomain: api.PtrTo("example.org"),
},
{
name: "SPF neutral",
receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"",
expectedResult: model.AuthResultResultNeutral,
expectedDomain: utils.PtrTo("domain.net"),
expectedResult: api.AuthResultResultNeutral,
expectedDomain: api.PtrTo("domain.net"),
},
{
name: "SPF none",
receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"",
expectedResult: model.AuthResultResultNone,
expectedDomain: utils.PtrTo("company.io"),
expectedResult: api.AuthResultResultNone,
expectedDomain: api.PtrTo("company.io"),
},
{
name: "SPF temperror",
receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"",
expectedResult: model.AuthResultResultTemperror,
expectedDomain: utils.PtrTo("shop.example"),
expectedResult: api.AuthResultResultTemperror,
expectedDomain: api.PtrTo("shop.example"),
},
{
name: "SPF permerror",
receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"",
expectedResult: model.AuthResultResultPermerror,
expectedDomain: utils.PtrTo("invalid.test"),
expectedResult: api.AuthResultResultPermerror,
expectedDomain: api.PtrTo("invalid.test"),
},
{
name: "SPF pass without domain extraction",
receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
expectedDomain: nil,
},
{
@ -157,12 +156,12 @@ func TestParseLegacySPF(t *testing.T) {
{
name: "SPF with unquoted envelope-from",
receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net",
expectedResult: model.AuthResultResultPass,
expectedDomain: utils.PtrTo("mail.example.net"),
expectedResult: api.AuthResultResultPass,
expectedDomain: api.PtrTo("mail.example.net"),
},
}
analyzer := NewAuthenticationAnalyzer("")
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -24,84 +24,83 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
func TestGetAuthenticationScore(t *testing.T) {
tests := []struct {
name string
results *model.AuthenticationResults
results *api.AuthenticationResults
expectedScore int
}{
{
name: "Perfect authentication (SPF + DKIM + DMARC)",
results: &model.AuthenticationResults{
Spf: &model.AuthResult{
Result: model.AuthResultResultPass,
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]model.AuthResult{
{Result: model.AuthResultResultPass},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &model.AuthResult{
Result: model.AuthResultResultPass,
Dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
},
},
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
},
{
name: "SPF and DKIM only",
results: &model.AuthenticationResults{
Spf: &model.AuthResult{
Result: model.AuthResultResultPass,
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]model.AuthResult{
{Result: model.AuthResultResultPass},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
},
expectedScore: 48, // SPF=25 + DKIM=23
},
{
name: "SPF fail, DKIM pass",
results: &model.AuthenticationResults{
Spf: &model.AuthResult{
Result: model.AuthResultResultFail,
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultFail,
},
Dkim: &[]model.AuthResult{
{Result: model.AuthResultResultPass},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
},
expectedScore: 23, // SPF=0 + DKIM=23
},
{
name: "SPF softfail",
results: &model.AuthenticationResults{
Spf: &model.AuthResult{
Result: model.AuthResultResultSoftfail,
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultSoftfail,
},
},
expectedScore: 4,
},
{
name: "No authentication",
results: &model.AuthenticationResults{},
results: &api.AuthenticationResults{},
expectedScore: 0,
},
{
name: "BIMI adds to score",
results: &model.AuthenticationResults{
Spf: &model.AuthResult{
Result: model.AuthResultResultPass,
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Bimi: &model.AuthResult{
Result: model.AuthResultResultPass,
Bimi: &api.AuthResult{
Result: api.AuthResultResultPass,
},
},
expectedScore: 35, // SPF (25) + BIMI (10)
},
}
scorer := NewAuthenticationAnalyzer("")
scorer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -118,30 +117,30 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
tests := []struct {
name string
header string
expectedSPFResult *model.AuthResultResult
expectedSPFResult *api.AuthResultResult
expectedSPFDomain *string
expectedDKIMCount int
expectedDKIMResult *model.AuthResultResult
expectedDMARCResult *model.AuthResultResult
expectedDKIMResult *api.AuthResultResult
expectedDMARCResult *api.AuthResultResult
expectedDMARCDomain *string
expectedBIMIResult *model.AuthResultResult
expectedARCResult *model.ARCResultResult
expectedBIMIResult *api.AuthResultResult
expectedARCResult *api.ARCResultResult
}{
{
name: "Complete authentication results",
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com",
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedSPFDomain: api.PtrTo("example.com"),
expectedDKIMCount: 1,
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCDomain: utils.PtrTo("example.com"),
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCDomain: api.PtrTo("example.com"),
},
{
name: "SPF only",
header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com",
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedSPFDomain: utils.PtrTo("domain.com"),
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedSPFDomain: api.PtrTo("domain.com"),
expectedDKIMCount: 0,
expectedDMARCResult: nil,
},
@ -150,68 +149,68 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1",
expectedSPFResult: nil,
expectedDKIMCount: 1,
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
},
{
name: "Multiple DKIM signatures",
header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2",
expectedSPFResult: nil,
expectedDKIMCount: 2,
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCResult: nil,
},
{
name: "SPF fail with DKIM pass",
header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default",
expectedSPFResult: utils.PtrTo(model.AuthResultResultFail),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedSPFResult: api.PtrTo(api.AuthResultResultFail),
expectedSPFDomain: api.PtrTo("example.com"),
expectedDKIMCount: 1,
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCResult: nil,
},
{
name: "SPF softfail",
header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com",
expectedSPFResult: utils.PtrTo(model.AuthResultResultSoftfail),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedSPFResult: api.PtrTo(api.AuthResultResultSoftfail),
expectedSPFDomain: api.PtrTo("example.com"),
expectedDKIMCount: 0,
expectedDMARCResult: nil,
},
{
name: "DMARC fail",
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com",
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedDKIMCount: 1,
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCResult: utils.PtrTo(model.AuthResultResultFail),
expectedDMARCDomain: utils.PtrTo("example.com"),
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCResult: api.PtrTo(api.AuthResultResultFail),
expectedDMARCDomain: api.PtrTo("example.com"),
},
{
name: "BIMI pass",
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default",
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedSPFDomain: api.PtrTo("example.com"),
expectedDKIMCount: 0,
expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass),
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
},
{
name: "ARC pass",
header: "mail.example.com; arc=pass",
expectedSPFResult: nil,
expectedDKIMCount: 0,
expectedARCResult: utils.PtrTo(model.ARCResultResultPass),
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
},
{
name: "All authentication methods",
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass",
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedSPFDomain: api.PtrTo("example.com"),
expectedDKIMCount: 1,
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass),
expectedDMARCDomain: utils.PtrTo("example.com"),
expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass),
expectedARCResult: utils.PtrTo(model.ARCResultResultPass),
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
expectedDMARCDomain: api.PtrTo("example.com"),
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
},
{
name: "Empty header (authserv-id only)",
@ -222,8 +221,8 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
{
name: "Empty parts with semicolons",
header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;",
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
expectedSPFDomain: api.PtrTo("example.com"),
expectedDKIMCount: 0,
},
{
@ -231,28 +230,28 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
header: "mail.example.com; dkim=pass d=example.com s=selector1",
expectedSPFResult: nil,
expectedDKIMCount: 1,
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
},
{
name: "SPF neutral",
header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com",
expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral),
expectedSPFDomain: utils.PtrTo("example.com"),
expectedSPFResult: api.PtrTo(api.AuthResultResultNeutral),
expectedSPFDomain: api.PtrTo("example.com"),
expectedDKIMCount: 0,
},
{
name: "SPF none",
header: "mail.example.com; spf=none",
expectedSPFResult: utils.PtrTo(model.AuthResultResultNone),
expectedSPFResult: api.PtrTo(api.AuthResultResultNone),
expectedDKIMCount: 0,
},
}
analyzer := NewAuthenticationAnalyzer("")
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &model.AuthenticationResults{}
results := &api.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(tt.header, results)
// Check SPF
@ -354,17 +353,17 @@ 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"
results := &model.AuthenticationResults{}
results := &api.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Spf == nil {
t.Fatal("Expected SPF result, got nil")
}
if results.Spf.Result != model.AuthResultResultPass {
if results.Spf.Result != api.AuthResultResultPass {
t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result)
}
if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" {
@ -374,13 +373,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) {
header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com"
results := &model.AuthenticationResults{}
results := &api.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Dmarc == nil {
t.Fatal("Expected DMARC result, got nil")
}
if results.Dmarc.Result != model.AuthResultResultPass {
if results.Dmarc.Result != api.AuthResultResultPass {
t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result)
}
if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" {
@ -390,26 +389,26 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) {
header := "mail.example.com; arc=pass; arc=fail"
results := &model.AuthenticationResults{}
results := &api.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Arc == nil {
t.Fatal("Expected ARC result, got nil")
}
if results.Arc.Result != model.ARCResultResultPass {
if results.Arc.Result != api.ARCResultResultPass {
t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result)
}
})
t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) {
header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com"
results := &model.AuthenticationResults{}
results := &api.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Bimi == nil {
t.Fatal("Expected BIMI result, got nil")
}
if results.Bimi.Result != model.AuthResultResultPass {
if results.Bimi.Result != api.AuthResultResultPass {
t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result)
}
if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" {
@ -420,7 +419,7 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) {
// DKIM is special - multiple signatures should all be collected
header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2"
results := &model.AuthenticationResults{}
results := &api.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(header, results)
if results.Dkim == nil {
@ -429,10 +428,10 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
if len(*results.Dkim) != 2 {
t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim))
}
if (*results.Dkim)[0].Result != model.AuthResultResultPass {
if (*results.Dkim)[0].Result != api.AuthResultResultPass {
t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result)
}
if (*results.Dkim)[1].Result != model.AuthResultResultFail {
if (*results.Dkim)[1].Result != api.AuthResultResultFail {
t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result)
}
})

View file

@ -25,35 +25,34 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
// Example: x-aligned-from=pass (Address match)
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *model.AuthResult {
result := &model.AuthResult{}
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`x-aligned-from=([\w]+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.AuthResultResult(resultStr)
result.Result = api.AuthResultResult(resultStr)
}
// Extract details (everything after the result)
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
return result
}
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) {
if results.XAlignedFrom != nil {
switch results.XAlignedFrom.Result {
case model.AuthResultResultPass:
case api.AuthResultResultPass:
// pass: positive contribution
return 100
case model.AuthResultResultFail:
case api.AuthResultResultFail:
// fail: negative contribution
return 0
default:
@ -62,5 +61,5 @@ func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.Authe
}
}
return 100
return 0
}

View file

@ -24,49 +24,49 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseXAlignedFromResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedResult api.AuthResultResult
expectedDetail string
}{
{
name: "x-aligned-from pass with details",
part: "x-aligned-from=pass (Address match)",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
expectedDetail: "pass (Address match)",
},
{
name: "x-aligned-from fail with reason",
part: "x-aligned-from=fail (Address mismatch)",
expectedResult: model.AuthResultResultFail,
expectedResult: api.AuthResultResultFail,
expectedDetail: "fail (Address mismatch)",
},
{
name: "x-aligned-from pass minimal",
part: "x-aligned-from=pass",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
expectedDetail: "pass",
},
{
name: "x-aligned-from neutral",
part: "x-aligned-from=neutral (No alignment check performed)",
expectedResult: model.AuthResultResultNeutral,
expectedResult: api.AuthResultResultNeutral,
expectedDetail: "neutral (No alignment check performed)",
},
{
name: "x-aligned-from none",
part: "x-aligned-from=none",
expectedResult: model.AuthResultResultNone,
expectedResult: api.AuthResultResultNone,
expectedDetail: "none",
},
}
analyzer := NewAuthenticationAnalyzer("")
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -88,34 +88,34 @@ func TestParseXAlignedFromResult(t *testing.T) {
func TestCalculateXAlignedFromScore(t *testing.T) {
tests := []struct {
name string
result *model.AuthResult
result *api.AuthResult
expectedScore int
}{
{
name: "pass result gives positive score",
result: &model.AuthResult{
Result: model.AuthResultResultPass,
result: &api.AuthResult{
Result: api.AuthResultResultPass,
},
expectedScore: 100,
},
{
name: "fail result gives zero score",
result: &model.AuthResult{
Result: model.AuthResultResultFail,
result: &api.AuthResult{
Result: api.AuthResultResultFail,
},
expectedScore: 0,
},
{
name: "neutral result gives zero score",
result: &model.AuthResult{
Result: model.AuthResultResultNeutral,
result: &api.AuthResult{
Result: api.AuthResultResultNeutral,
},
expectedScore: 0,
},
{
name: "none result gives zero score",
result: &model.AuthResult{
Result: model.AuthResultResultNone,
result: &api.AuthResult{
Result: api.AuthResultResultNone,
},
expectedScore: 0,
},
@ -126,11 +126,11 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
},
}
analyzer := NewAuthenticationAnalyzer("")
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &model.AuthenticationResults{
results := &api.AuthenticationResults{
XAlignedFrom: tt.result,
}

View file

@ -25,20 +25,19 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results
// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6
func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.AuthResult {
result := &model.AuthResult{}
func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`x-google-dkim=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.AuthResultResult(resultStr)
result.Result = api.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
@ -55,15 +54,15 @@ func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.Auth
result.Selector = &selector
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
return result
}
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) {
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) {
if results.XGoogleDkim != nil {
switch results.XGoogleDkim.Result {
case model.AuthResultResultPass:
case api.AuthResultResultPass:
// pass: don't alter the score
default: // fail
return -100

View file

@ -24,43 +24,43 @@ package analyzer
import (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseXGoogleDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedResult api.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "x-google-dkim pass with domain",
part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
expectedDomain: "1e100.net",
},
{
name: "x-google-dkim pass with short form",
part: "x-google-dkim=pass d=gmail.com",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
expectedDomain: "gmail.com",
},
{
name: "x-google-dkim fail",
part: "x-google-dkim=fail header.d=example.com",
expectedResult: model.AuthResultResultFail,
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "x-google-dkim with minimal info",
part: "x-google-dkim=pass",
expectedResult: model.AuthResultResultPass,
expectedResult: api.AuthResultResultPass,
},
}
analyzer := NewAuthenticationAnalyzer("")
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -32,8 +32,7 @@ import (
"time"
"unicode"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
"golang.org/x/net/html"
)
@ -729,16 +728,16 @@ func (c *ContentAnalyzer) normalizeText(text string) string {
}
// GenerateContentAnalysis creates structured content analysis from results
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *model.ContentAnalysis {
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis {
if results == nil {
return nil
}
analysis := &model.ContentAnalysis{
HasHtml: utils.PtrTo(results.HTMLContent != ""),
HasPlaintext: utils.PtrTo(results.TextContent != ""),
HasUnsubscribeLink: utils.PtrTo(results.HasUnsubscribe),
UnsubscribeMethods: &[]model.ContentAnalysisUnsubscribeMethods{},
analysis := &api.ContentAnalysis{
HasHtml: api.PtrTo(results.HTMLContent != ""),
HasPlaintext: api.PtrTo(results.TextContent != ""),
HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{},
}
// Calculate text-to-image ratio (inverse of image-to-text)
@ -751,16 +750,16 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
}
// Build HTML issues
htmlIssues := []model.ContentIssue{}
htmlIssues := []api.ContentIssue{}
// Add HTML parsing errors
if !results.HTMLValid && len(results.HTMLErrors) > 0 {
for _, errMsg := range results.HTMLErrors {
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.BrokenHtml,
Severity: model.ContentIssueSeverityHigh,
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.BrokenHtml,
Severity: api.ContentIssueSeverityHigh,
Message: errMsg,
Advice: utils.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
})
}
}
@ -774,53 +773,53 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
}
}
if missingAltCount > 0 {
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.MissingAlt,
Severity: model.ContentIssueSeverityMedium,
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.MissingAlt,
Severity: api.ContentIssueSeverityMedium,
Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount),
Advice: utils.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
})
}
}
// Add excessive images issue
if results.ImageTextRatio > 10.0 {
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.ExcessiveImages,
Severity: model.ContentIssueSeverityMedium,
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.ExcessiveImages,
Severity: api.ContentIssueSeverityMedium,
Message: "Email is excessively image-heavy",
Advice: utils.PtrTo("Reduce the number of images relative to text content"),
Advice: api.PtrTo("Reduce the number of images relative to text content"),
})
}
// Add suspicious URL issues
for _, suspURL := range results.SuspiciousURLs {
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.SuspiciousLink,
Severity: model.ContentIssueSeverityHigh,
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.SuspiciousLink,
Severity: api.ContentIssueSeverityHigh,
Message: "Suspicious URL detected",
Location: &suspURL,
Advice: utils.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
})
}
// Add harmful HTML tag issues
for _, harmfulIssue := range results.HarmfullIssues {
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.DangerousHtml,
Severity: model.ContentIssueSeverityCritical,
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.DangerousHtml,
Severity: api.ContentIssueSeverityCritical,
Message: harmfulIssue,
Advice: utils.PtrTo("Remove dangerous HTML tags like <script>, <iframe>, <object>, <embed>, <applet>, <form>, and <base> from email content"),
Advice: api.PtrTo("Remove dangerous HTML tags like <script>, <iframe>, <object>, <embed>, <applet>, <form>, and <base> from email content"),
})
}
// Add general content issues (like external stylesheets)
for _, contentIssue := range results.ContentIssues {
htmlIssues = append(htmlIssues, model.ContentIssue{
Type: model.BrokenHtml,
Severity: model.ContentIssueSeverityLow,
htmlIssues = append(htmlIssues, api.ContentIssue{
Type: api.BrokenHtml,
Severity: api.ContentIssueSeverityLow,
Message: contentIssue,
Advice: utils.PtrTo("Use inline CSS instead of external stylesheets for better email compatibility"),
Advice: api.PtrTo("Use inline CSS instead of external stylesheets for better email compatibility"),
})
}
@ -830,31 +829,31 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
// Convert links
if len(results.Links) > 0 {
links := make([]model.LinkCheck, 0, len(results.Links))
links := make([]api.LinkCheck, 0, len(results.Links))
for _, link := range results.Links {
status := model.Valid
status := api.Valid
if link.Status >= 400 {
status = model.Broken
status = api.Broken
} else if !link.IsSafe {
status = model.Suspicious
status = api.Suspicious
} else if link.Warning != "" {
status = model.Timeout
status = api.Timeout
}
apiLink := model.LinkCheck{
apiLink := api.LinkCheck{
Url: link.URL,
Status: status,
}
if link.Status > 0 {
apiLink.HttpCode = utils.PtrTo(link.Status)
apiLink.HttpCode = api.PtrTo(link.Status)
}
// Check if it's a URL shortener
parsedURL, err := url.Parse(link.URL)
if err == nil {
isShortened := c.isSuspiciousURL(link.URL, parsedURL)
apiLink.IsShortened = utils.PtrTo(isShortened)
apiLink.IsShortened = api.PtrTo(isShortened)
}
links = append(links, apiLink)
@ -864,9 +863,9 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
// Convert images
if len(results.Images) > 0 {
images := make([]model.ImageCheck, 0, len(results.Images))
images := make([]api.ImageCheck, 0, len(results.Images))
for _, img := range results.Images {
apiImg := model.ImageCheck{
apiImg := api.ImageCheck{
HasAlt: img.HasAlt,
}
if img.Src != "" {
@ -876,7 +875,7 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
apiImg.AltText = &img.AltText
}
// Simple heuristic: tracking pixels are typically 1x1
apiImg.IsTrackingPixel = utils.PtrTo(false)
apiImg.IsTrackingPixel = api.PtrTo(false)
images = append(images, apiImg)
}
@ -885,19 +884,19 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
// Unsubscribe methods
if results.HasUnsubscribe {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.Link)
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Link)
}
for _, url := range c.listUnsubscribeURLs {
if strings.HasPrefix(url, "mailto:") {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.Mailto)
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Mailto)
} else if strings.HasPrefix(url, "http:") || strings.HasPrefix(url, "https:") {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.ListUnsubscribeHeader)
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader)
}
}
if slices.Contains(*analysis.UnsubscribeMethods, model.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.OneClick)
if slices.Contains(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.OneClick)
}
return analysis

View file

@ -24,7 +24,7 @@ package analyzer
import (
"time"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
// DNSAnalyzer analyzes DNS records for email domains
@ -54,16 +54,16 @@ func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DN
}
// AnalyzeDNS performs DNS validation for the email's domain
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.HeaderAnalysis) *model.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 &model.DNSResults{
return &api.DNSResults{
Errors: &[]string{"Unable to extract domain from email"},
}
}
fromDomain := *headersResults.DomainAlignment.FromDomain
results := &model.DNSResults{
results := &api.DNSResults{
FromDomain: fromDomain,
RpDomain: headersResults.DomainAlignment.ReturnPathDomain,
}
@ -104,14 +104,19 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
// 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([]model.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)
}
}
@ -127,8 +132,8 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
// AnalyzeDomainOnly performs DNS validation for a domain without email context
// This is useful for checking domain configuration without sending an actual email
func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
results := &model.DNSResults{
func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
results := &api.DNSResults{
FromDomain: domain,
}
@ -150,7 +155,7 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
// CalculateDomainOnlyScore calculates the DNS score for domain-only tests
// Returns a score from 0-100 where higher is better
// This version excludes PTR and DKIM checks since they require email context
func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int, string) {
func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *api.DNSResults) (int, string) {
if results == nil {
return 0, ""
}
@ -192,7 +197,7 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int,
// CalculateDNSScore calculates the DNS score from records results
// Returns a score from 0-100 where higher is better
// senderIP is the original sender IP address used for FCrDNS verification
func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP string) (int, string) {
func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string) (int, string) {
if results == nil {
return 0, ""
}

View file

@ -27,12 +27,11 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord {
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord {
// BIMI records are at: selector._bimi.domain
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
@ -41,20 +40,20 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
if err != nil {
return &model.BIMIRecord{
return &api.BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
}
}
if len(txtRecords) == 0 {
return &model.BIMIRecord{
return &api.BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: utils.PtrTo("No BIMI record found"),
Error: api.PtrTo("No BIMI record found"),
}
}
@ -67,18 +66,18 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
if !d.validateBIMI(bimiRecord) {
return &model.BIMIRecord{
return &api.BIMIRecord{
Selector: selector,
Domain: domain,
Record: &bimiRecord,
LogoUrl: &logoURL,
VmcUrl: &vmcURL,
Valid: false,
Error: utils.PtrTo("BIMI record appears malformed"),
Error: api.PtrTo("BIMI record appears malformed"),
}
}
return &model.BIMIRecord{
return &api.BIMIRecord{
Selector: selector,
Domain: domain,
Record: &bimiRecord,

View file

@ -26,44 +26,11 @@ import (
"fmt"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"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
}
// checkmodel.DKIMRecord looks up and validates DKIM record for a domain and selector
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord {
// 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
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
@ -72,20 +39,20 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
if err != nil {
return &model.DKIMRecord{
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
}
}
if len(txtRecords) == 0 {
return &model.DKIMRecord{
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: utils.PtrTo("No DKIM record found"),
Error: api.PtrTo("No DKIM record found"),
}
}
@ -94,16 +61,16 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
if !d.validateDKIM(dkimRecord) {
return &model.DKIMRecord{
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
Record: utils.PtrTo(dkimRecord),
Record: api.PtrTo(dkimRecord),
Valid: false,
Error: utils.PtrTo("DKIM record appears malformed"),
Error: api.PtrTo("DKIM record appears malformed"),
}
}
return &model.DKIMRecord{
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
Record: &dkimRecord,
@ -127,7 +94,7 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool {
return true
}
func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) {
func (d *DNSAnalyzer) calculateDKIMScore(results *api.DNSResults) (score int) {
// DKIM provides strong email authentication
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
hasValidDKIM := false

View file

@ -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

View file

@ -27,12 +27,11 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// checkmodel.DMARCRecord looks up and validates DMARC record for a domain
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
// checkapi.DMARCRecord looks up and validates DMARC record for a domain
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord {
// DMARC records are at: _dmarc.domain
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
@ -41,9 +40,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
if err != nil {
return &model.DMARCRecord{
return &api.DMARCRecord{
Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
}
}
@ -57,9 +56,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
}
if dmarcRecord == "" {
return &model.DMARCRecord{
return &api.DMARCRecord{
Valid: false,
Error: utils.PtrTo("No DMARC record found"),
Error: api.PtrTo("No DMARC record found"),
}
}
@ -78,21 +77,21 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
// Basic validation
if !d.validateDMARC(dmarcRecord) {
return &model.DMARCRecord{
return &api.DMARCRecord{
Record: &dmarcRecord,
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
SubdomainPolicy: subdomainPolicy,
Percentage: percentage,
SpfAlignment: spfAlignment,
DkimAlignment: dkimAlignment,
Valid: false,
Error: utils.PtrTo("DMARC record appears malformed"),
Error: api.PtrTo("DMARC record appears malformed"),
}
}
return &model.DMARCRecord{
return &api.DMARCRecord{
Record: &dmarcRecord,
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
SubdomainPolicy: subdomainPolicy,
Percentage: percentage,
SpfAlignment: spfAlignment,
@ -114,44 +113,44 @@ func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record
// Returns "relaxed" (default) or "strict"
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *model.DMARCRecordSpfAlignment {
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment {
// Look for aspf=s (strict) or aspf=r (relaxed)
re := regexp.MustCompile(`aspf=(r|s)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
if matches[1] == "s" {
return utils.PtrTo(model.DMARCRecordSpfAlignmentStrict)
return api.PtrTo(api.DMARCRecordSpfAlignmentStrict)
}
return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
}
// Default is relaxed if not specified
return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
}
// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record
// Returns "relaxed" (default) or "strict"
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *model.DMARCRecordDkimAlignment {
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment {
// Look for adkim=s (strict) or adkim=r (relaxed)
re := regexp.MustCompile(`adkim=(r|s)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
if matches[1] == "s" {
return utils.PtrTo(model.DMARCRecordDkimAlignmentStrict)
return api.PtrTo(api.DMARCRecordDkimAlignmentStrict)
}
return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
}
// Default is relaxed if not specified
return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
}
// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record
// Returns the sp tag value or nil if not specified (defaults to main policy)
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *model.DMARCRecordSubdomainPolicy {
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy {
// Look for sp=none, sp=quarantine, or sp=reject
re := regexp.MustCompile(`sp=(none|quarantine|reject)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return utils.PtrTo(model.DMARCRecordSubdomainPolicy(matches[1]))
return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1]))
}
// If sp is not specified, it defaults to the main policy (p tag)
// Return nil to indicate it's using the default
@ -192,7 +191,7 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool {
return true
}
func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) {
func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) {
// DMARC ties SPF and DKIM together and provides policy
if results.DmarcRecord != nil {
if results.DmarcRecord.Valid {
@ -211,10 +210,10 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int)
}
}
// Bonus points for strict alignment modes (2 points each)
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict {
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict {
score += 5
}
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict {
score += 5
}
// Subdomain policy scoring (sp tag)

View file

@ -25,8 +25,7 @@ import (
"testing"
"time"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
func TestExtractDMARCPolicy(t *testing.T) {
@ -229,17 +228,17 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) {
{
name: "Subdomain policy - none",
record: "v=DMARC1; p=quarantine; sp=none",
expectedPolicy: utils.PtrTo("none"),
expectedPolicy: api.PtrTo("none"),
},
{
name: "Subdomain policy - quarantine",
record: "v=DMARC1; p=reject; sp=quarantine",
expectedPolicy: utils.PtrTo("quarantine"),
expectedPolicy: api.PtrTo("quarantine"),
},
{
name: "Subdomain policy - reject",
record: "v=DMARC1; p=quarantine; sp=reject",
expectedPolicy: utils.PtrTo("reject"),
expectedPolicy: api.PtrTo("reject"),
},
{
name: "No subdomain policy specified (defaults to main policy)",
@ -249,7 +248,7 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) {
{
name: "Complex record with subdomain policy",
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100",
expectedPolicy: utils.PtrTo("quarantine"),
expectedPolicy: api.PtrTo("quarantine"),
},
}
@ -283,22 +282,22 @@ func TestExtractDMARCPercentage(t *testing.T) {
{
name: "Percentage - 100",
record: "v=DMARC1; p=quarantine; pct=100",
expectedPercentage: utils.PtrTo(100),
expectedPercentage: api.PtrTo(100),
},
{
name: "Percentage - 50",
record: "v=DMARC1; p=quarantine; pct=50",
expectedPercentage: utils.PtrTo(50),
expectedPercentage: api.PtrTo(50),
},
{
name: "Percentage - 25",
record: "v=DMARC1; p=reject; pct=25",
expectedPercentage: utils.PtrTo(25),
expectedPercentage: api.PtrTo(25),
},
{
name: "Percentage - 0",
record: "v=DMARC1; p=none; pct=0",
expectedPercentage: utils.PtrTo(0),
expectedPercentage: api.PtrTo(0),
},
{
name: "No percentage specified (defaults to 100)",
@ -308,7 +307,7 @@ func TestExtractDMARCPercentage(t *testing.T) {
{
name: "Complex record with percentage",
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75",
expectedPercentage: utils.PtrTo(75),
expectedPercentage: api.PtrTo(75),
},
{
name: "Invalid percentage > 100 (ignored)",

View file

@ -24,7 +24,7 @@ package analyzer
import (
"context"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA)
@ -63,7 +63,7 @@ func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) {
}
// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability
func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP string) (score int) {
func (d *DNSAnalyzer) calculatePTRScore(results *api.DNSResults, senderIP string) (score int) {
if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
// 50 points for having PTR records
score += 50

View file

@ -25,37 +25,36 @@ import (
"context"
"fmt"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// checkMXRecords looks up MX records for a domain
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]model.MXRecord {
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
mxRecords, err := d.resolver.LookupMX(ctx, domain)
if err != nil {
return &[]model.MXRecord{
return &[]api.MXRecord{
{
Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
},
}
}
if len(mxRecords) == 0 {
return &[]model.MXRecord{
return &[]api.MXRecord{
{
Valid: false,
Error: utils.PtrTo("No MX records found"),
Error: api.PtrTo("No MX records found"),
},
}
}
var results []model.MXRecord
var results []api.MXRecord
for _, mx := range mxRecords {
results = append(results, model.MXRecord{
results = append(results, api.MXRecord{
Host: mx.Host,
Priority: mx.Pref,
Valid: true,
@ -65,7 +64,7 @@ func (d *DNSAnalyzer) checkMXRecords(domain string) *[]model.MXRecord {
return &results
}
func (d *DNSAnalyzer) calculateMXScore(results *model.DNSResults) (score int) {
func (d *DNSAnalyzer) calculateMXScore(results *api.DNSResults) (score int) {
// Having valid MX records is critical for email deliverability
// From domain MX records (half points) - needed for replies
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {

View file

@ -27,34 +27,33 @@ import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]model.SPFRecord {
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord {
visited := make(map[string]bool)
return d.resolveSPFRecords(domain, visited, 0, true)
}
// resolveSPFRecords recursively resolves SPF records including include: directives
// isMainRecord indicates if this is the primary domain's record (not an included one)
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]model.SPFRecord {
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]api.SPFRecord {
const maxDepth = 10 // Prevent infinite recursion
if depth > maxDepth {
return &[]model.SPFRecord{
return &[]api.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: utils.PtrTo("Maximum SPF include depth exceeded"),
Error: api.PtrTo("Maximum SPF include depth exceeded"),
},
}
}
// Prevent circular references
if visited[domain] {
return &[]model.SPFRecord{}
return &[]api.SPFRecord{}
}
visited[domain] = true
@ -63,11 +62,11 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
if err != nil {
return &[]model.SPFRecord{
return &[]api.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
},
}
}
@ -83,23 +82,23 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
}
if spfCount == 0 {
return &[]model.SPFRecord{
return &[]api.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: utils.PtrTo("No SPF record found"),
Error: api.PtrTo("No SPF record found"),
},
}
}
var results []model.SPFRecord
var results []api.SPFRecord
if spfCount > 1 {
results = append(results, model.SPFRecord{
results = append(results, api.SPFRecord{
Domain: &domain,
Record: &spfRecord,
Valid: false,
Error: utils.PtrTo("Multiple SPF records found (RFC violation)"),
Error: api.PtrTo("Multiple SPF records found (RFC violation)"),
})
return &results
}
@ -108,28 +107,28 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
validationErr := d.validateSPF(spfRecord, isMainRecord)
// Extract the "all" mechanism qualifier
var allQualifier *model.SPFRecordAllQualifier
var allQualifier *api.SPFRecordAllQualifier
var errMsg *string
if validationErr != nil {
errMsg = utils.PtrTo(validationErr.Error())
errMsg = api.PtrTo(validationErr.Error())
} else {
// Extract qualifier from the "all" mechanism
if strings.HasSuffix(spfRecord, " -all") {
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("-"))
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-"))
} else if strings.HasSuffix(spfRecord, " ~all") {
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("~"))
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~"))
} else if strings.HasSuffix(spfRecord, " +all") {
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
} else if strings.HasSuffix(spfRecord, " ?all") {
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("?"))
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?"))
} else if strings.HasSuffix(spfRecord, " all") {
// Implicit + qualifier (default)
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
}
}
results = append(results, model.SPFRecord{
results = append(results, api.SPFRecord{
Domain: &domain,
Record: &spfRecord,
Valid: validationErr == nil,
@ -302,7 +301,7 @@ func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool {
return strings.HasSuffix(record, " -all")
}
func (d *DNSAnalyzer) calculateSPFScore(results *model.DNSResults) (score int) {
func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) {
// SPF is essential for email authentication
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
// Find the main SPF record by skipping redirects

View file

@ -31,8 +31,7 @@ import (
"golang.org/x/net/publicsuffix"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// HeaderAnalyzer analyzes email header quality and structure
@ -44,7 +43,7 @@ func NewHeaderAnalyzer() *HeaderAnalyzer {
}
// CalculateHeaderScore evaluates email structural quality from header analysis
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *model.HeaderAnalysis) (int, rune) {
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int, rune) {
if analysis == nil || analysis.Headers == nil {
return 0, ' '
}
@ -110,13 +109,6 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *model.HeaderAnalysis) (i
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
@ -188,7 +180,7 @@ func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) {
}
// isNoReplyAddress checks if a header check represents a no-reply email address
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck model.HeaderCheck) bool {
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool {
if !headerCheck.Present || headerCheck.Value == nil {
return false
}
@ -244,18 +236,18 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
}
// GenerateHeaderAnalysis creates structured header analysis from email
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *model.AuthenticationResults) *model.HeaderAnalysis {
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis {
if email == nil {
return nil
}
analysis := &model.HeaderAnalysis{}
analysis := &api.HeaderAnalysis{}
// Check for proper MIME structure
analysis.HasMimeStructure = utils.PtrTo(len(email.Parts) > 0)
analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0)
// Initialize headers map
headers := make(map[string]model.HeaderCheck)
headers := make(map[string]api.HeaderCheck)
// Check required headers
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
@ -274,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 {
@ -309,12 +297,12 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults
}
// checkHeader checks if a header is present and valid
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *model.HeaderCheck {
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck {
value := email.GetHeaderValue(headerName)
present := email.HasHeader(headerName) && value != ""
importanceEnum := model.HeaderCheckImportance(importance)
check := &model.HeaderCheck{
importanceEnum := api.HeaderCheckImportance(importance)
check := &api.HeaderCheck{
Present: present,
Importance: &importanceEnum,
}
@ -332,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 {
@ -375,10 +354,10 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
}
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *model.AuthenticationResults) *model.DomainAlignment {
alignment := &model.DomainAlignment{
Aligned: utils.PtrTo(true),
RelaxedAligned: utils.PtrTo(true),
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment {
alignment := &api.DomainAlignment{
Aligned: api.PtrTo(true),
RelaxedAligned: api.PtrTo(true),
}
// Extract From domain
@ -406,13 +385,13 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
}
// Extract DKIM domains from authentication results
var dkimDomains []model.DKIMDomainInfo
var dkimDomains []api.DKIMDomainInfo
if authResults != nil && authResults.Dkim != nil {
for _, dkim := range *authResults.Dkim {
if dkim.Domain != nil && *dkim.Domain != "" {
domain := *dkim.Domain
orgDomain := h.getOrganizationalDomain(domain)
dkimDomains = append(dkimDomains, model.DKIMDomainInfo{
dkimDomains = append(dkimDomains, api.DKIMDomainInfo{
Domain: domain,
OrgDomain: orgDomain,
})
@ -561,18 +540,18 @@ func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string {
}
// findHeaderIssues identifies issues with headers
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIssue {
var issues []model.HeaderIssue
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue {
var issues []api.HeaderIssue
// Check for missing required headers
requiredHeaders := []string{"From", "Date", "Message-ID"}
for _, header := range requiredHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
issues = append(issues, model.HeaderIssue{
issues = append(issues, api.HeaderIssue{
Header: header,
Severity: model.HeaderIssueSeverityCritical,
Severity: api.HeaderIssueSeverityCritical,
Message: fmt.Sprintf("Required header '%s' is missing", header),
Advice: utils.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
Advice: api.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
})
}
}
@ -580,11 +559,11 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIss
// Check Message-ID format
messageID := email.GetHeaderValue("Message-ID")
if messageID != "" && !h.isValidMessageID(messageID) {
issues = append(issues, model.HeaderIssue{
issues = append(issues, api.HeaderIssue{
Header: "Message-ID",
Severity: model.HeaderIssueSeverityMedium,
Severity: api.HeaderIssueSeverityMedium,
Message: "Message-ID format is invalid",
Advice: utils.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
Advice: api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
})
}
@ -592,7 +571,7 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIss
}
// parseReceivedChain extracts the chain of Received headers from an email
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop {
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop {
if email == nil || email.Header == nil {
return nil
}
@ -602,7 +581,7 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.Receive
return nil
}
var chain []model.ReceivedHop
var chain []api.ReceivedHop
for _, receivedValue := range receivedHeaders {
hop := h.parseReceivedHeader(receivedValue)
@ -615,8 +594,8 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.Receive
}
// parseReceivedHeader parses a single Received header value
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.ReceivedHop {
hop := &model.ReceivedHop{}
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.ReceivedHop {
hop := &api.ReceivedHop{}
// Normalize whitespace - Received headers can span multiple lines
normalized := strings.Join(strings.Fields(receivedValue), " ")

View file

@ -27,7 +27,7 @@ import (
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
func TestCalculateHeaderScore(t *testing.T) {
@ -404,7 +404,7 @@ func TestParseReceivedChain(t *testing.T) {
name string
receivedHeaders []string
expectedHops int
validateFirst func(*testing.T, *EmailMessage, []model.ReceivedHop)
validateFirst func(*testing.T, *EmailMessage, []api.ReceivedHop)
}{
{
name: "No Received headers",
@ -417,7 +417,7 @@ func TestParseReceivedChain(t *testing.T) {
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
@ -450,7 +450,7 @@ func TestParseReceivedChain(t *testing.T) {
"from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000",
},
expectedHops: 2,
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
if len(hops) != 2 {
t.Fatalf("Expected 2 hops, got %d", len(hops))
}
@ -472,7 +472,7 @@ func TestParseReceivedChain(t *testing.T) {
"from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
@ -499,7 +499,7 @@ func TestParseReceivedChain(t *testing.T) {
for <test-9a9ce364-c394-4fa9-acef-d46ff2f482bf@deliver.happydomain.org>; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`,
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
@ -527,7 +527,7 @@ func TestParseReceivedChain(t *testing.T) {
"from unknown by localhost",
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
@ -1012,16 +1012,16 @@ func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) {
}
// Create authentication results with DKIM signatures
var authResults *model.AuthenticationResults
var authResults *api.AuthenticationResults
if len(tt.dkimDomains) > 0 {
dkimResults := make([]model.AuthResult, 0, len(tt.dkimDomains))
dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains))
for _, domain := range tt.dkimDomains {
dkimResults = append(dkimResults, model.AuthResult{
Result: model.AuthResultResultPass,
dkimResults = append(dkimResults, api.AuthResult{
Result: api.AuthResultResultPass,
Domain: &domain,
})
}
authResults = &model.AuthenticationResults{
authResults = &api.AuthenticationResults{
Dkim: &dkimResults,
}
}

View file

@ -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)

View file

@ -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))
}

View file

@ -27,22 +27,17 @@ import (
"net"
"regexp"
"strings"
"sync"
"time"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"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
@ -53,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][]model.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{
Checks: make(map[string][]model.BlacklistCheck),
// 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
@ -137,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
}
@ -157,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) ([]model.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([]model.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++
}
@ -186,19 +131,27 @@ func (r *DNSListChecker) CheckIP(ip string) ([]model.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
@ -206,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) {
@ -222,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
}
@ -239,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) model.BlacklistCheck {
check := model.BlacklistCheck{
Rbl: list,
// checkIP checks a single IP against a single RBL
func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
check := api.BlacklistCheck{
Rbl: rbl,
}
// Reverse the IP for DNSBL query
reversedIP := r.reverseIP(ip)
if reversedIP == "" {
check.Error = utils.PtrTo("Failed to reverse IP address")
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
}
}
check.Error = utils.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
// 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 = utils.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 = utils.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
}
}
@ -283,58 +250,44 @@ func (r *DNSListChecker) checkIP(ip, list string) model.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
}
}
}
@ -342,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
}

View file

@ -26,7 +26,7 @@ import (
"testing"
"time"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewRBLChecker(t *testing.T) {
@ -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,8 +335,8 @@ func TestGetBlacklistScore(t *testing.T) {
}
func TestGetUniqueListedIPs(t *testing.T) {
results := &DNSListResults{
Checks: map[string][]model.BlacklistCheck{
results := &RBLResults{
Checks: map[string][]api.BlacklistCheck{
"198.51.100.1": {
{Rbl: "zen.spamhaus.org", Listed: true},
{Rbl: "bl.spamcop.net", Listed: true},
@ -363,8 +363,8 @@ func TestGetUniqueListedIPs(t *testing.T) {
}
func TestGetRBLsForIP(t *testing.T) {
results := &DNSListResults{
Checks: map[string][]model.BlacklistCheck{
results := &RBLResults{
Checks: map[string][]api.BlacklistCheck{
"198.51.100.1": {
{Rbl: "zen.spamhaus.org", Listed: true},
{Rbl: "bl.spamcop.net", 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))

View file

@ -24,7 +24,7 @@ package analyzer
import (
"time"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/utils"
"github.com/google/uuid"
)
@ -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(),
}
@ -66,14 +61,13 @@ func NewReportGenerator(
// AnalysisResults contains all intermediate analysis results
type AnalysisResults struct {
Email *EmailMessage
Authentication *model.AuthenticationResults
Authentication *api.AuthenticationResults
Content *ContentResults
DNS *model.DNSResults
Headers *model.HeaderAnalysis
RBL *DNSListResults
DNSWL *DNSListResults
SpamAssassin *model.SpamAssassinResult
Rspamd *model.RspamdResult
DNS *api.DNSResults
Headers *api.HeaderAnalysis
RBL *RBLResults
SpamAssassin *api.SpamAssassinResult
Rspamd *api.RspamdResult
}
// AnalyzeEmail performs complete email analysis
@ -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)
@ -96,11 +89,11 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
}
// GenerateReport creates a complete API report from analysis results
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *model.Report {
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report {
reportID := uuid.New()
now := time.Now()
report := &model.Report{
report := &api.Report{
Id: utils.UUIDToBase32(reportID),
TestId: utils.UUIDToBase32(testID),
CreatedAt: now,
@ -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)
@ -169,19 +160,19 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
spamGrade = MinGrade(saGrade, rspamdGrade)
}
report.Summary = &model.ScoreSummary{
report.Summary = &api.ScoreSummary{
DnsScore: dnsScore,
DnsGrade: model.ScoreSummaryDnsGrade(dnsGrade),
DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade),
AuthenticationScore: authScore,
AuthenticationGrade: model.ScoreSummaryAuthenticationGrade(authGrade),
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
BlacklistScore: blacklistScore,
BlacklistGrade: model.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
ContentScore: contentScore,
ContentGrade: model.ScoreSummaryContentGrade(contentGrade),
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
HeaderScore: headerScore,
HeaderGrade: model.ScoreSummaryHeaderGrade(headerGrade),
HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade),
SpamScore: spamScore,
SpamGrade: model.ScoreSummarySpamGrade(spamGrade),
SpamGrade: api.ScoreSummarySpamGrade(spamGrade),
}
// Add authentication results
@ -206,23 +197,18 @@ 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 := model.SpamAssassinResultDeliverabilityGrade(saGrade)
results.SpamAssassin.DeliverabilityScore = utils.PtrTo(saScore)
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)
results.SpamAssassin.DeliverabilityScore = api.PtrTo(saScore)
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
}
report.Spamassassin = results.SpamAssassin
// Add rspamd result with individual deliverability score
if results.Rspamd != nil {
rspamdGradeTyped := model.RspamdResultDeliverabilityGrade(rspamdGrade)
results.Rspamd.DeliverabilityScore = utils.PtrTo(rspamdScore)
rspamdGradeTyped := api.RspamdResultDeliverabilityGrade(rspamdGrade)
results.Rspamd.DeliverabilityScore = api.PtrTo(rspamdScore)
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
}
report.Rspamd = results.Rspamd
@ -288,7 +274,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
}
if minusGrade < 255 {
report.Grade = model.ReportGrade(string([]byte{'A' + minusGrade}))
report.Grade = api.ReportGrade(string([]byte{'A' + minusGrade}))
}
}

View file

@ -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

View file

@ -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

View file

@ -27,7 +27,7 @@ import (
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
// Default rspamd action thresholds (rspamd built-in defaults)
@ -37,38 +37,27 @@ 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
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult {
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
headers := email.GetRspamdHeaders()
if len(headers) == 0 {
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 := &model.RspamdResult{
Symbols: make(map[string]model.SpamTestDetail),
result := &api.RspamdResult{
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) *model.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
@ -107,7 +86,7 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult
// parseSpamdResult parses the X-Spamd-Result header
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdResult) {
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResult) {
// Extract score and threshold from the first line
// e.g. "default: False [-3.91 / 15.00]"
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
@ -132,16 +111,15 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdRes
}
// 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 := model.SpamTestDetail{
sym := api.RspamdSymbol{
Name: name,
Score: float32(score),
}
@ -155,7 +133,7 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdRes
}
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
func (a *RspamdAnalyzer) CalculateRspamdScore(result *model.RspamdResult) (int, string) {
func (a *RspamdAnalyzer) CalculateRspamdScore(result *api.RspamdResult) (int, string) {
if result == nil {
return 100, "" // rspamd not installed
}

View file

@ -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)
}

View file

@ -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/model"
)
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 := &model.RspamdResult{
Symbols: make(map[string]model.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 *model.RspamdResult
expectedScore int
expectedGrade string
}{
{
name: "Nil result (rspamd not installed)",
result: nil,
expectedScore: 100,
expectedGrade: "",
},
{
name: "Score well below threshold",
result: &model.RspamdResult{
Score: -3.91,
Threshold: 15.00,
},
expectedScore: 100,
expectedGrade: "A+",
},
{
name: "Score at zero",
result: &model.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: &model.RspamdResult{
Score: 15.00,
Threshold: 15.00,
},
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
expectedScore: 50,
},
{
name: "Score above 2*threshold",
result: &model.RspamdResult{
Score: 31.00,
Threshold: 15.00,
},
expectedScore: 0,
expectedGrade: "F",
},
{
name: "Score exactly at 2*threshold",
result: &model.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)
}
}

View file

@ -22,7 +22,7 @@
package analyzer
import (
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
// ScoreToGrade converts a percentage score (0-100) to a letter grade
@ -65,16 +65,14 @@ func ScoreToGradeKind(score int) string {
}
}
// ScoreToReportGrade converts a percentage score to an model.ReportGrade
func ScoreToReportGrade(score int) model.ReportGrade {
return model.ReportGrade(ScoreToGrade(score))
// ScoreToReportGrade converts a percentage score to an api.ReportGrade
func ScoreToReportGrade(score int) api.ReportGrade {
return api.ReportGrade(ScoreToGrade(score))
}
// 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":

View file

@ -27,8 +27,7 @@ import (
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
@ -40,22 +39,14 @@ func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
}
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.SpamAssassinResult {
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.SpamAssassinResult {
headers := email.GetSpamAssassinHeaders()
if len(headers) == 0 {
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 := &model.SpamAssassinResult{
TestDetails: make(map[string]model.SpamTestDetail),
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail),
}
// Parse X-Spam-Status header
@ -77,13 +68,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.S
// Parse X-Spam-Report header for detailed test results
if reportHeader, ok := headers["X-Spam-Report"]; ok {
result.Report = utils.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
result.Report = api.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
a.parseSpamReport(reportHeader, result)
}
// Parse X-Spam-Checker-Version
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
result.Version = utils.PtrTo(strings.TrimSpace(versionHeader))
result.Version = api.PtrTo(strings.TrimSpace(versionHeader))
}
return result
@ -91,7 +82,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.S
// parseSpamStatus parses the X-Spam-Status header
// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.SpamAssassinResult) {
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAssassinResult) {
// Check if spam (first word)
parts := strings.SplitN(header, ",", 2)
if len(parts) > 0 {
@ -135,7 +126,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.Spam
// * 0.0 TEST_NAME Description line 1
// * continuation line 2
// * continuation line 3
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.SpamAssassinResult) {
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAssassinResult) {
segments := strings.Split(report, "*")
// Regex to match test lines: score TEST_NAME Description
@ -157,7 +148,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
// Save previous test if exists
if currentTestName != "" {
description := strings.TrimSpace(currentDescription.String())
detail := model.SpamTestDetail{
detail := api.SpamTestDetail{
Name: currentTestName,
Score: result.TestDetails[currentTestName].Score,
Description: &description,
@ -175,7 +166,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
currentDescription.WriteString(description)
// Initialize with score
result.TestDetails[testName] = model.SpamTestDetail{
result.TestDetails[testName] = api.SpamTestDetail{
Name: testName,
Score: float32(score),
}
@ -192,7 +183,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
// Save the last test if exists
if currentTestName != "" {
description := strings.TrimSpace(currentDescription.String())
detail := model.SpamTestDetail{
detail := api.SpamTestDetail{
Name: currentTestName,
Score: result.TestDetails[currentTestName].Score,
Description: &description,
@ -202,7 +193,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
}
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *model.SpamAssassinResult) (int, string) {
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) (int, string) {
if result == nil {
return 100, "" // No spam scan results, assume good
}

View file

@ -27,8 +27,7 @@ import (
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseSpamStatus(t *testing.T) {
@ -78,8 +77,8 @@ func TestParseSpamStatus(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &model.SpamAssassinResult{
TestDetails: make(map[string]model.SpamTestDetail),
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail),
}
analyzer.parseSpamStatus(tt.header, result)
@ -116,27 +115,27 @@ func TestParseSpamReport(t *testing.T) {
`
analyzer := NewSpamAssassinAnalyzer()
result := &model.SpamAssassinResult{
TestDetails: make(map[string]model.SpamTestDetail),
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail),
}
analyzer.parseSpamReport(report, result)
expectedTests := map[string]model.SpamTestDetail{
expectedTests := map[string]api.SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
Description: utils.PtrTo("Bayes spam probability is 99 to 100%"),
Description: api.PtrTo("Bayes spam probability is 99 to 100%"),
},
"SPOOFED_SENDER": {
Name: "SPOOFED_SENDER",
Score: 3.5,
Description: utils.PtrTo("From address doesn't match envelope sender"),
Description: api.PtrTo("From address doesn't match envelope sender"),
},
"ALL_TRUSTED": {
Name: "ALL_TRUSTED",
Score: -1.0,
Description: utils.PtrTo("All mail servers are trusted"),
Description: api.PtrTo("All mail servers are trusted"),
},
}
@ -158,7 +157,7 @@ func TestParseSpamReport(t *testing.T) {
func TestGetSpamAssassinScore(t *testing.T) {
tests := []struct {
name string
result *model.SpamAssassinResult
result *api.SpamAssassinResult
expectedScore int
minScore int
maxScore int
@ -170,7 +169,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "Excellent score (negative)",
result: &model.SpamAssassinResult{
result: &api.SpamAssassinResult{
Score: -2.5,
RequiredScore: 5.0,
},
@ -178,7 +177,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "Good score (below threshold)",
result: &model.SpamAssassinResult{
result: &api.SpamAssassinResult{
Score: 2.0,
RequiredScore: 5.0,
},
@ -186,7 +185,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "Score at threshold",
result: &model.SpamAssassinResult{
result: &api.SpamAssassinResult{
Score: 5.0,
RequiredScore: 5.0,
},
@ -194,7 +193,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "Above threshold (spam)",
result: &model.SpamAssassinResult{
result: &api.SpamAssassinResult{
Score: 6.0,
RequiredScore: 5.0,
},
@ -202,7 +201,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "High spam score",
result: &model.SpamAssassinResult{
result: &api.SpamAssassinResult{
Score: 12.0,
RequiredScore: 5.0,
},
@ -210,7 +209,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
},
{
name: "Very high spam score",
result: &model.SpamAssassinResult{
result: &api.SpamAssassinResult{
Score: 20.0,
RequiredScore: 5.0,
},

2184
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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": {

View file

@ -70,10 +70,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
appConfig["custom_logo_url"] = cfg.CustomLogoURL
}
if !cfg.DisableTestList {
appConfig["test_list_enabled"] = true
}
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
log.Println("Unable to generate JSON config to inject in web application")
} else {
@ -99,7 +95,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
router.GET("/domain/:domain", serveOrReverse("/", cfg))
router.GET("/test/", serveOrReverse("/", cfg))
router.GET("/test/:testid", serveOrReverse("/", cfg))
router.GET("/history/", serveOrReverse("/", cfg))
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/img/*path", serveOrReverse("", cfg))

View file

@ -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}

View file

@ -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">

View file

@ -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>

View file

@ -11,7 +11,7 @@
headerScore?: number;
}
let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props();
</script>
<div class="card shadow-sm" id="header-details">

View file

@ -1,72 +0,0 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { TestSummary } from "$lib/api/types.gen";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props {
tests: TestSummary[];
}
let { tests }: Props = $props();
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<div class="table-responsive shadow-sm">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr>
<th class="ps-4" style="width: 80px;">Grade</th>
<th style="width: 80px;">Score</th>
<th>Domain</th>
<th>Date</th>
<th style="width: 50px;"></th>
</tr>
</thead>
<tbody>
{#each tests as test}
<tr class="cursor-pointer" onclick={() => goto(`/test/${test.test_id}`)}>
<td class="ps-4">
<GradeDisplay grade={test.grade} size="small" />
</td>
<td>
<span class="badge bg-secondary">{test.score}%</span>
</td>
<td>
{#if test.from_domain}
<code>{test.from_domain}</code>
{:else}
<span class="text-muted">-</span>
{/if}
</td>
<td class="text-muted">
{formatDate(test.created_at)}
</td>
<td>
<i class="bi bi-chevron-right text-muted"></i>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.cursor-pointer {
cursor: pointer;
}
.cursor-pointer:hover td {
background-color: var(--bs-tertiary-bg);
}
</style>

View file

@ -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">

View file

@ -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);

View file

@ -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 " });

View file

@ -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>

View file

@ -23,6 +23,4 @@ export { default as RspamdCard } from "./RspamdCard.svelte";
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
export { default as SummaryCard } from "./SummaryCard.svelte";
export { default as HistoryTable } from "./HistoryTable.svelte";
export { default as TinySurvey } from "./TinySurvey.svelte";
export { default as WhitelistCard } from "./WhitelistCard.svelte";

View file

@ -25,8 +25,6 @@ interface AppConfig {
report_retention?: number;
survey_url?: string;
custom_logo_url?: string;
rbls?: string[];
test_list_enabled?: boolean;
}
const defaultConfig: AppConfig = {

View file

@ -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";
};

View file

@ -40,17 +40,7 @@
<Logo color={$theme === "light" ? "black" : "white"} />
{/if}
</a>
{#if $appConfig.test_list_enabled}
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/history/">
<i class="bi bi-clock-history me-1"></i>
History
</a>
</li>
</ul>
{/if}
<div class="d-flex align-items-center">
<div>
<span class="d-none d-md-inline navbar-text text-primary small">
Open-Source Email Deliverability Tester
</span>

View file

@ -1,30 +1,12 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { createTest as apiCreateTest, listTests } from "$lib/api";
import type { TestSummary } from "$lib/api/types.gen";
import { FeatureCard, HowItWorksStep, HistoryTable } from "$lib/components";
import { createTest as apiCreateTest } from "$lib/api";
import { FeatureCard, HowItWorksStep } from "$lib/components";
import { appConfig } from "$lib/stores/config";
let loading = $state(false);
let error = $state<string | null>(null);
let recentTests = $state<TestSummary[]>([]);
async function loadRecentTests() {
if (!$appConfig.test_list_enabled) return;
try {
const response = await listTests({ query: { offset: 0, limit: 5 } });
if (response.data) {
recentTests = response.data.tests;
}
} catch {
// Silently ignore — this is a non-critical section
}
}
$effect(() => {
loadRecentTests();
});
async function createTest() {
loading = true;
@ -194,32 +176,6 @@
</div>
</section>
<!-- Recently Tested -->
{#if $appConfig.test_list_enabled && recentTests.length > 0}
<section class="py-5 border-bottom border-3" id="recent">
<div class="container py-4">
<div class="row text-center mb-5">
<div class="col-lg-8 mx-auto">
<h2 class="display-5 fw-bold mb-3">Recently Tested</h2>
<p class="text-muted">Latest deliverability reports from this instance</p>
</div>
</div>
<div class="row">
<div class="col-lg-10 mx-auto">
<HistoryTable tests={recentTests} />
<div class="text-center mt-4">
<a href="/history/" class="btn btn-outline-primary">
<i class="bi bi-clock-history me-2"></i>
View All Tests
</a>
</div>
</div>
</div>
</div>
</section>
{/if}
<!-- Features Section -->
<section class="py-5" id="features">
<div class="container py-4">

View file

@ -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">

View file

@ -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>

View file

@ -1,189 +0,0 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { listTests, createTest as apiCreateTest } from "$lib/api";
import type { TestSummary } from "$lib/api/types.gen";
import { HistoryTable } from "$lib/components";
let tests = $state<TestSummary[]>([]);
let total = $state(0);
let offset = $state(0);
let limit = $state(20);
let loading = $state(true);
let error = $state<string | null>(null);
let creatingTest = $state(false);
async function loadTests() {
loading = true;
error = null;
try {
const response = await listTests({ query: { offset, limit } });
if (response.data) {
tests = response.data.tests;
total = response.data.total;
} else if (response.error) {
if (
response.error &&
typeof response.error === "object" &&
"error" in response.error &&
response.error.error === "feature_disabled"
) {
error = "Test listing is disabled on this instance.";
} else {
error = "Failed to load tests.";
}
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to load tests.";
} finally {
loading = false;
}
}
$effect(() => {
loadTests();
});
function goToPage(newOffset: number) {
offset = newOffset;
loadTests();
}
async function createTest() {
creatingTest = true;
try {
const response = await apiCreateTest();
if (response.data) {
goto(`/test/${response.data.id}`);
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to create test";
} finally {
creatingTest = false;
}
}
let totalPages = $derived(Math.ceil(total / limit));
let currentPage = $derived(Math.floor(offset / limit) + 1);
</script>
<svelte:head>
<title>Test History - happyDeliver</title>
</svelte:head>
<div class="container py-5">
<div class="row">
<div class="col-lg-10 mx-auto">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="display-6 fw-bold mb-0">
<i class="bi bi-clock-history me-2"></i>
Test History
</h1>
<button
class="btn btn-primary"
onclick={createTest}
disabled={creatingTest}
>
{#if creatingTest}
<span
class="spinner-border spinner-border-sm me-2"
role="status"
></span>
{:else}
<i class="bi bi-plus-lg me-1"></i>
{/if}
New Test
</button>
</div>
{#if loading}
<div class="text-center py-5">
<div
class="spinner-border text-primary"
role="status"
style="width: 3rem; height: 3rem;"
>
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading tests...</p>
</div>
{:else if error}
<div class="alert alert-warning text-center" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
{error}
</div>
{:else if tests.length === 0}
<div class="text-center py-5">
<i
class="bi bi-inbox display-1 text-muted mb-3 d-block"
></i>
<h2 class="h4 text-muted mb-3">No tests yet</h2>
<p class="text-muted mb-4">
Send a test email to get your first deliverability
report.
</p>
<button
class="btn btn-primary btn-lg"
onclick={createTest}
disabled={creatingTest}
>
<i class="bi bi-envelope-plus me-2"></i>
Start Your First Test
</button>
</div>
{:else}
<HistoryTable {tests} />
<!-- Pagination -->
{#if totalPages > 1}
<nav class="mt-4 d-flex justify-content-between align-items-center">
<small class="text-muted">
Showing {offset + 1}-{Math.min(
offset + limit,
total,
)} of {total} tests
</small>
<ul class="pagination mb-0">
<li
class="page-item"
class:disabled={currentPage === 1}
>
<button
class="page-link"
onclick={() =>
goToPage(
Math.max(0, offset - limit),
)}
disabled={currentPage === 1}
>
<i class="bi bi-chevron-left"></i>
Previous
</button>
</li>
<li class="page-item disabled">
<span class="page-link">
Page {currentPage} of {totalPages}
</span>
</li>
<li
class="page-item"
class:disabled={currentPage === totalPages}
>
<button
class="page-link"
onclick={() =>
goToPage(offset + limit)}
disabled={currentPage === totalPages}
>
Next
<i class="bi bi-chevron-right"></i>
</button>
</li>
</ul>
</nav>
{/if}
{/if}
</div>
</div>
</div>

View file

@ -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}