Compare commits

..

1 commit

Author SHA1 Message Date
14d5680970 chore(deps): update dependency typescript to v6
Some checks failed
renovate/artifacts Artifact file update failure
2026-03-23 19:07:49 +00:00
73 changed files with 1846 additions and 9690 deletions

4
.gitignore vendored
View file

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

View file

@ -175,8 +175,7 @@ ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
HAPPYDELIVER_DOMAIN=happydeliver.local \ HAPPYDELIVER_DOMAIN=happydeliver.local \
HAPPYDELIVER_ADDRESS_PREFIX=test- \ HAPPYDELIVER_ADDRESS_PREFIX=test- \
HAPPYDELIVER_DNS_TIMEOUT=5s \ HAPPYDELIVER_DNS_TIMEOUT=5s \
HAPPYDELIVER_HTTP_TIMEOUT=10s \ HAPPYDELIVER_HTTP_TIMEOUT=10s
HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334
# Volume for persistent data # Volume for persistent data
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"] VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]

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, ... 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. happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
#### Receiver Hostname Choose one of the following way to integrate happyDeliver in your existing setup:
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.
#### Postfix LMTP Transport #### 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). **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 ## Scoring System
The deliverability score is calculated from A to F based on: The deliverability score is calculated from A to F based on:

View file

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

View file

@ -1,8 +1,5 @@
package: api package: api
generate: generate:
gin-server: true gin-server: true
models: true
embedded-spec: true embedded-spec: true
output: internal/api/server.gen.go 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: The container accepts these environment variables:
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local) - `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`). Example:
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):
```bash ```bash
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ... 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 ## Volumes
**Required volumes:** **Required volumes:**

View file

@ -21,5 +21,5 @@
package main 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 //go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml

32
go.mod
View file

@ -3,20 +3,18 @@ module git.happydns.org/happyDeliver
go 1.25.0 go 1.25.0
require ( require (
github.com/JGLTechnologies/gin-rate-limit v1.5.8 github.com/JGLTechnologies/gin-rate-limit v1.5.6
github.com/emersion/go-smtp v0.24.0 github.com/emersion/go-smtp v0.24.0
github.com/getkin/kin-openapi v0.135.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/oapi-codegen/runtime v1.3.0 github.com/oapi-codegen/runtime v1.3.0
golang.org/x/net v0.53.0 golang.org/x/net v0.52.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
require ( require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
@ -26,6 +24,7 @@ require (
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect
@ -50,31 +49,30 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 // indirect github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
github.com/oasdiff/yaml v0.0.9 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.9 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/redis/go-redis/v9 v9.17.2 // indirect
github.com/speakeasy-api/jsonpath v0.6.3 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi v1.19.2 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.34.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // 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 gopkg.in/yaml.v3 v3.0.1 // indirect
) )

78
go.sum
View file

@ -1,9 +1,5 @@
github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0= github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI=
github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g= github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@ -22,9 +18,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
@ -39,8 +34,8 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI= 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 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
@ -103,7 +98,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -131,14 +125,14 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 h1:/8daqIYZfwnsHEAZdHUu9m0D5LA+5DoJCP7zLlT5Cs0= github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
github.com/oapi-codegen/oapi-codegen/v2 v2.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA= github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
@ -155,24 +149,22 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -196,26 +188,20 @@ 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 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= 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.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= 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-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -223,8 +209,8 @@ 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-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-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.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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-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.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -245,21 +231,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-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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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/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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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-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.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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" openapi_types "github.com/oapi-codegen/runtime/types"
"git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/storage" "git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/version" "git.happydns.org/happyDeliver/internal/version"
@ -41,8 +40,8 @@ import (
// This interface breaks the circular dependency with pkg/analyzer // This interface breaks the circular dependency with pkg/analyzer
type EmailAnalyzer interface { type EmailAnalyzer interface {
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
AnalyzeDomain(domain string) (dnsResults *model.DNSResults, score int, grade string) AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.BlacklistCheck, listedCount int, score int, grade string, err error) CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error)
} }
// APIHandler implements the ServerInterface for handling API requests // APIHandler implements the ServerInterface for handling API requests
@ -80,11 +79,11 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
) )
// Return response // Return response
c.JSON(http.StatusCreated, model.TestResponse{ c.JSON(http.StatusCreated, TestResponse{
Id: base32ID, Id: base32ID,
Email: openapi_types.Email(email), Email: openapi_types.Email(email),
Status: model.TestResponseStatusPending, Status: TestResponseStatusPending,
Message: utils.PtrTo("Send your test email to the given address"), 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 // Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id) testUUID, err := utils.Base32ToUUID(id)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, model.Error{ c.JSON(http.StatusBadRequest, Error{
Error: "invalid_id", Error: "invalid_id",
Message: "Invalid test ID format", Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
@ -105,20 +104,20 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
// Check if a report exists for this test ID // Check if a report exists for this test ID
reportExists, err := h.storage.ReportExists(testUUID) reportExists, err := h.storage.ReportExists(testUUID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{ c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error", Error: "internal_error",
Message: "Failed to check test status", Message: "Failed to check test status",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
// Determine status based on report existence // Determine status based on report existence
var apiStatus model.TestStatus var apiStatus TestStatus
if reportExists { if reportExists {
apiStatus = model.TestStatusAnalyzed apiStatus = TestStatusAnalyzed
} else { } else {
apiStatus = model.TestStatusPending apiStatus = TestStatusPending
} }
// Generate test email address using Base32-encoded UUID // 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, h.config.Email.Domain,
) )
c.JSON(http.StatusOK, model.Test{ c.JSON(http.StatusOK, Test{
Id: id, Id: id,
Email: openapi_types.Email(email), Email: openapi_types.Email(email),
Status: apiStatus, Status: apiStatus,
@ -141,10 +140,10 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
// Convert base32 ID to UUID // Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id) testUUID, err := utils.Base32ToUUID(id)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, model.Error{ c.JSON(http.StatusBadRequest, Error{
Error: "invalid_id", Error: "invalid_id",
Message: "Invalid test ID format", Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
@ -152,16 +151,16 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
reportJSON, _, err := h.storage.GetReport(testUUID) reportJSON, _, err := h.storage.GetReport(testUUID)
if err != nil { if err != nil {
if err == storage.ErrNotFound { if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, model.Error{ c.JSON(http.StatusNotFound, Error{
Error: "not_found", Error: "not_found",
Message: "Report not found", Message: "Report not found",
}) })
return return
} }
c.JSON(http.StatusInternalServerError, model.Error{ c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error", Error: "internal_error",
Message: "Failed to retrieve report", Message: "Failed to retrieve report",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
@ -176,10 +175,10 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
// Convert base32 ID to UUID // Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id) testUUID, err := utils.Base32ToUUID(id)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, model.Error{ c.JSON(http.StatusBadRequest, Error{
Error: "invalid_id", Error: "invalid_id",
Message: "Invalid test ID format", Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
@ -187,16 +186,16 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
_, rawEmail, err := h.storage.GetReport(testUUID) _, rawEmail, err := h.storage.GetReport(testUUID)
if err != nil { if err != nil {
if err == storage.ErrNotFound { if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, model.Error{ c.JSON(http.StatusNotFound, Error{
Error: "not_found", Error: "not_found",
Message: "Email not found", Message: "Email not found",
}) })
return return
} }
c.JSON(http.StatusInternalServerError, model.Error{ c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error", Error: "internal_error",
Message: "Failed to retrieve raw email", Message: "Failed to retrieve raw email",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
@ -210,10 +209,10 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
// Convert base32 ID to UUID // Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id) testUUID, err := utils.Base32ToUUID(id)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, model.Error{ c.JSON(http.StatusBadRequest, Error{
Error: "invalid_id", Error: "invalid_id",
Message: "Invalid test ID format", Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
@ -222,16 +221,16 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
_, rawEmail, err := h.storage.GetReport(testUUID) _, rawEmail, err := h.storage.GetReport(testUUID)
if err != nil { if err != nil {
if err == storage.ErrNotFound { if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, model.Error{ c.JSON(http.StatusNotFound, Error{
Error: "not_found", Error: "not_found",
Message: "Email not found", Message: "Email not found",
}) })
return return
} }
c.JSON(http.StatusInternalServerError, model.Error{ c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error", Error: "internal_error",
Message: "Failed to retrieve email", Message: "Failed to retrieve email",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
@ -239,20 +238,20 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
// Re-analyze the email using the current analyzer // Re-analyze the email using the current analyzer
reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID) reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{ c.JSON(http.StatusInternalServerError, Error{
Error: "analysis_error", Error: "analysis_error",
Message: "Failed to re-analyze email", Message: "Failed to re-analyze email",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
// Update the report in storage // Update the report in storage
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil { if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
c.JSON(http.StatusInternalServerError, model.Error{ c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error", Error: "internal_error",
Message: "Failed to update report", Message: "Failed to update report",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
@ -268,24 +267,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
uptime := int(time.Since(h.startTime).Seconds()) uptime := int(time.Since(h.startTime).Seconds())
// Check database connectivity by trying to check if a report exists // 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 { if _, err := h.storage.ReportExists(uuid.New()); err != nil {
dbStatus = model.StatusComponentsDatabaseDown dbStatus = StatusComponentsDatabaseDown
} }
// Determine overall status // Determine overall status
overallStatus := model.Healthy overallStatus := Healthy
if dbStatus == model.StatusComponentsDatabaseDown { if dbStatus == StatusComponentsDatabaseDown {
overallStatus = model.Unhealthy overallStatus = Unhealthy
} }
mtaStatus := model.StatusComponentsMtaUp mtaStatus := StatusComponentsMtaUp
c.JSON(http.StatusOK, model.Status{ c.JSON(http.StatusOK, Status{
Status: overallStatus, Status: overallStatus,
Version: version.Version, Version: version.Version,
Components: &struct { Components: &struct {
Database *model.StatusComponentsDatabase `json:"database,omitempty"` Database *StatusComponentsDatabase `json:"database,omitempty"`
Mta *model.StatusComponentsMta `json:"mta,omitempty"` Mta *StatusComponentsMta `json:"mta,omitempty"`
}{ }{
Database: &dbStatus, Database: &dbStatus,
Mta: &mtaStatus, Mta: &mtaStatus,
@ -297,14 +296,14 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
// TestDomain performs synchronous domain analysis // TestDomain performs synchronous domain analysis
// (POST /domain) // (POST /domain)
func (h *APIHandler) TestDomain(c *gin.Context) { func (h *APIHandler) TestDomain(c *gin.Context) {
var request model.DomainTestRequest var request DomainTestRequest
// Bind and validate request // Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, model.Error{ c.JSON(http.StatusBadRequest, Error{
Error: "invalid_request", Error: "invalid_request",
Message: "Invalid request body", Message: "Invalid request body",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
@ -313,28 +312,28 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain) dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
// Convert grade string to DomainTestResponseGrade enum // Convert grade string to DomainTestResponseGrade enum
var responseGrade model.DomainTestResponseGrade var responseGrade DomainTestResponseGrade
switch grade { switch grade {
case "A+": case "A+":
responseGrade = model.DomainTestResponseGradeA responseGrade = DomainTestResponseGradeA
case "A": case "A":
responseGrade = model.DomainTestResponseGradeA1 responseGrade = DomainTestResponseGradeA1
case "B": case "B":
responseGrade = model.DomainTestResponseGradeB responseGrade = DomainTestResponseGradeB
case "C": case "C":
responseGrade = model.DomainTestResponseGradeC responseGrade = DomainTestResponseGradeC
case "D": case "D":
responseGrade = model.DomainTestResponseGradeD responseGrade = DomainTestResponseGradeD
case "E": case "E":
responseGrade = model.DomainTestResponseGradeE responseGrade = DomainTestResponseGradeE
case "F": case "F":
responseGrade = model.DomainTestResponseGradeF responseGrade = DomainTestResponseGradeF
default: default:
responseGrade = model.DomainTestResponseGradeF responseGrade = DomainTestResponseGradeF
} }
// Build response // Build response
response := model.DomainTestResponse{ response := DomainTestResponse{
Domain: request.Domain, Domain: request.Domain,
Score: score, Score: score,
Grade: responseGrade, Grade: responseGrade,
@ -347,14 +346,14 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
// CheckBlacklist checks an IP address against DNS blacklists // CheckBlacklist checks an IP address against DNS blacklists
// (POST /blacklist) // (POST /blacklist)
func (h *APIHandler) CheckBlacklist(c *gin.Context) { func (h *APIHandler) CheckBlacklist(c *gin.Context) {
var request model.BlacklistCheckRequest var request BlacklistCheckRequest
// Bind and validate request // Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, model.Error{ c.JSON(http.StatusBadRequest, Error{
Error: "invalid_request", Error: "invalid_request",
Message: "Invalid request body", Message: "Invalid request body",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
@ -362,64 +361,23 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
// Perform blacklist check using analyzer // Perform blacklist check using analyzer
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip) checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, model.Error{ c.JSON(http.StatusBadRequest, Error{
Error: "invalid_ip", Error: "invalid_ip",
Message: "Invalid IP address", Message: "Invalid IP address",
Details: utils.PtrTo(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
// Build response // Build response
response := model.BlacklistCheckResponse{ response := BlacklistCheckResponse{
Ip: request.Ip, Ip: request.Ip,
Blacklists: checks, Blacklists: checks,
Whitelists: &whitelists, Whitelists: &whitelists,
ListedCount: listedCount, ListedCount: listedCount,
Score: score, Score: score,
Grade: model.BlacklistCheckResponseGrade(grade), Grade: BlacklistCheckResponseGrade(grade),
} }
c.JSON(http.StatusOK, response) 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. // This file is part of the happyDeliver (R) project.
// Copyright (c) 2026 happyDomain // Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al. // Authors: Pierre-Olivier Mercier, et al.
// //
// This program is offered under a commercial and under the AGPL license. // 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 // 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/>. // 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 // PtrTo returns a pointer to the provided value
func PtrTo[T any](v T) *T { 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.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.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.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.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.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)") flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)")
flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)")
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever") flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI") 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 // 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" openapi_types "github.com/oapi-codegen/runtime/types"
) )
func getHostname() string {
h, _ := os.Hostname()
return h
}
// Config represents the application configuration // Config represents the application configuration
type Config struct { type Config struct {
DevProxy string DevProxy string
@ -50,7 +45,6 @@ type Config struct {
RateLimit uint // API rate limit (requests per second per IP) RateLimit uint // API rate limit (requests per second per IP)
SurveyURL url.URL // URL for user feedback survey SurveyURL url.URL // URL for user feedback survey
CustomLogoURL string // URL for custom logo image in the web UI CustomLogoURL string // URL for custom logo image in the web UI
DisableTestList bool // Disable the public test listing endpoint
} }
// DatabaseConfig contains database connection settings // DatabaseConfig contains database connection settings
@ -64,7 +58,6 @@ type EmailConfig struct {
Domain string Domain string
TestAddressPrefix string TestAddressPrefix string
LMTPAddr string LMTPAddr string
ReceiverHostname string
} }
// AnalysisConfig contains timeout and behavior settings for email analysis // AnalysisConfig contains timeout and behavior settings for email analysis
@ -73,8 +66,7 @@ type AnalysisConfig struct {
HTTPTimeout time.Duration HTTPTimeout time.Duration
RBLs []string RBLs []string
DNSWLs []string DNSWLs []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one CheckAllIPs bool // Check all IPs found in headers, not just the first one
RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list)
} }
// DefaultConfig returns a configuration with sensible defaults // DefaultConfig returns a configuration with sensible defaults
@ -92,7 +84,6 @@ func DefaultConfig() *Config {
Domain: "happydeliver.local", Domain: "happydeliver.local",
TestAddressPrefix: "test-", TestAddressPrefix: "test-",
LMTPAddr: "127.0.0.1:2525", LMTPAddr: "127.0.0.1:2525",
ReceiverHostname: getHostname(),
}, },
Analysis: AnalysisConfig{ Analysis: AnalysisConfig{
DNSTimeout: 5 * time.Second, DNSTimeout: 5 * time.Second,

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) 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 // Marshal report to JSON
reportJSON, err := json.Marshal(result.Report) reportJSON, err := json.Marshal(result.Report)
if err != nil { if err != nil {

View file

@ -30,9 +30,6 @@ import (
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
) )
var ( var (
@ -48,7 +45,6 @@ type Storage interface {
ReportExists(testID uuid.UUID) (bool, error) ReportExists(testID uuid.UUID) (bool, error)
UpdateReport(testID uuid.UUID, reportJSON []byte) error UpdateReport(testID uuid.UUID, reportJSON []byte) error
DeleteOldReports(olderThan time.Time) (int64, error) DeleteOldReports(olderThan time.Time) (int64, error)
ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error)
// Close closes the database connection // Close closes the database connection
Close() error Close() error
@ -143,72 +139,6 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
return result.RowsAffected, nil 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 // Close closes the database connection
func (s *DBStorage) Close() error { func (s *DBStorage) Close() error {
sqlDB, err := s.db.DB() sqlDB, err := s.db.DB()

View file

@ -28,7 +28,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/config"
) )
@ -41,13 +41,11 @@ type EmailAnalyzer struct {
// NewEmailAnalyzer creates a new email analyzer with the given configuration // NewEmailAnalyzer creates a new email analyzer with the given configuration
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
generator := NewReportGenerator( generator := NewReportGenerator(
cfg.Email.ReceiverHostname,
cfg.Analysis.DNSTimeout, cfg.Analysis.DNSTimeout,
cfg.Analysis.HTTPTimeout, cfg.Analysis.HTTPTimeout,
cfg.Analysis.RBLs, cfg.Analysis.RBLs,
cfg.Analysis.DNSWLs, cfg.Analysis.DNSWLs,
cfg.Analysis.CheckAllIPs, cfg.Analysis.CheckAllIPs,
cfg.Analysis.RspamdAPIURL,
) )
return &EmailAnalyzer{ return &EmailAnalyzer{
@ -59,7 +57,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
type AnalysisResult struct { type AnalysisResult struct {
Email *EmailMessage Email *EmailMessage
Results *AnalysisResults Results *AnalysisResults
Report *model.Report Report *api.Report
} }
// AnalyzeEmailBytes performs complete email analysis from raw bytes // AnalyzeEmailBytes performs complete email analysis from raw bytes
@ -113,7 +111,7 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt
} }
// AnalyzeDomain performs DNS analysis for a domain and returns the results // 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 // Perform DNS analysis
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain) dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
@ -124,7 +122,7 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, strin
} }
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists // 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) { func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) {
// Check the IP against all configured RBLs // Check the IP against all configured RBLs
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip) checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
if err != nil { if err != nil {
@ -134,11 +132,11 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []mode
// Calculate score using the existing function // Calculate score using the existing function
// Create a minimal RBLResults structure for scoring // Create a minimal RBLResults structure for scoring
results := &DNSListResults{ results := &DNSListResults{
Checks: map[string][]model.BlacklistCheck{ip: checks}, Checks: map[string][]api.BlacklistCheck{ip: checks},
IPsChecked: []string{ip}, IPsChecked: []string{ip},
ListedCount: listedCount, ListedCount: listedCount,
} }
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false) score, grade := a.analyzer.generator.rblChecker.CalculateScore(results)
// Check the IP against all configured DNSWLs (informational only) // Check the IP against all configured DNSWLs (informational only)
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip) whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)

View file

@ -24,25 +24,23 @@ package analyzer
import ( import (
"strings" "strings"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/api"
) )
// AuthenticationAnalyzer analyzes email authentication results // AuthenticationAnalyzer analyzes email authentication results
type AuthenticationAnalyzer struct { type AuthenticationAnalyzer struct{}
receiverHostname string
}
// NewAuthenticationAnalyzer creates a new authentication analyzer // NewAuthenticationAnalyzer creates a new authentication analyzer
func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer { func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
return &AuthenticationAnalyzer{receiverHostname: receiverHostname} return &AuthenticationAnalyzer{}
} }
// AnalyzeAuthentication extracts and analyzes authentication results from email headers // AnalyzeAuthentication extracts and analyzes authentication results from email headers
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults { func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
results := &model.AuthenticationResults{} results := &api.AuthenticationResults{}
// Parse Authentication-Results headers // Parse Authentication-Results headers
authHeaders := email.GetAuthenticationResults(a.receiverHostname) authHeaders := email.GetAuthenticationResults()
for _, header := range authHeaders { for _, header := range authHeaders {
a.parseAuthenticationResultsHeader(header, results) a.parseAuthenticationResultsHeader(header, results)
} }
@ -65,7 +63,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *mod
// parseAuthenticationResultsHeader parses an Authentication-Results header // parseAuthenticationResultsHeader parses an Authentication-Results header
// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com // 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 // Split by semicolon to get individual results
parts := strings.Split(header, ";") parts := strings.Split(header, ";")
if len(parts) < 2 { if len(parts) < 2 {
@ -91,7 +89,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
dkimResult := a.parseDKIMResult(part) dkimResult := a.parseDKIMResult(part)
if dkimResult != nil { if dkimResult != nil {
if results.Dkim == nil { if results.Dkim == nil {
dkimList := []model.AuthResult{*dkimResult} dkimList := []api.AuthResult{*dkimResult}
results.Dkim = &dkimList results.Dkim = &dkimList
} else { } else {
*results.Dkim = append(*results.Dkim, *dkimResult) *results.Dkim = append(*results.Dkim, *dkimResult)
@ -145,39 +143,34 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
// CalculateAuthenticationScore calculates the authentication score from auth results // CalculateAuthenticationScore calculates the authentication score from auth results
// Returns a score from 0-100 where higher is better // 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 { if results == nil {
return 0, "" return 0, ""
} }
score := 0 score := 0
// Core authentication (90 points total) // IPRev (15 points)
// SPF (30 points) score += 15 * a.calculateIPRevScore(results) / 100
score += 30 * a.calculateSPFScore(results) / 100
// DKIM (30 points) // SPF (25 points)
score += 30 * a.calculateDKIMScore(results) / 100 score += 25 * a.calculateSPFScore(results) / 100
// DMARC (30 points) // DKIM (23 points)
score += 30 * a.calculateDMARCScore(results) / 100 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) // BIMI (10 points)
score += 10 * a.calculateBIMIScore(results) / 100 score += 10 * a.calculateBIMIScore(results) / 100
// Penalty-only: IPRev (up to -7 points on failure)
if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 {
score += 7 * (iprevScore - 100) / 100
}
// Penalty-only: X-Google-DKIM (up to -12 points on failure)
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
// Penalty-only: X-Aligned-From (up to -5 points on failure)
if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 {
score += 5 * (xAlignedScore - 100) / 100
}
// Ensure score doesn't exceed 100 // Ensure score doesn't exceed 100
if score > 100 { if score > 100 {
score = 100 score = 100

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,35 +25,34 @@ import (
"regexp" "regexp"
"strings" "strings"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/utils"
) )
// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results // parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
// Example: x-aligned-from=pass (Address match) // Example: x-aligned-from=pass (Address match)
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *model.AuthResult { func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult {
result := &model.AuthResult{} result := &api.AuthResult{}
// Extract result (pass, fail, etc.) // Extract result (pass, fail, etc.)
re := regexp.MustCompile(`x-aligned-from=([\w]+)`) re := regexp.MustCompile(`x-aligned-from=([\w]+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 { if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1]) resultStr := strings.ToLower(matches[1])
result.Result = model.AuthResultResult(resultStr) result.Result = api.AuthResultResult(resultStr)
} }
// Extract details (everything after the result) // 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 return result
} }
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) { func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) {
if results.XAlignedFrom != nil { if results.XAlignedFrom != nil {
switch results.XAlignedFrom.Result { switch results.XAlignedFrom.Result {
case model.AuthResultResultPass: case api.AuthResultResultPass:
// pass: positive contribution // pass: positive contribution
return 100 return 100
case model.AuthResultResultFail: case api.AuthResultResultFail:
// fail: negative contribution // fail: negative contribution
return 0 return 0
default: 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 ( import (
"testing" "testing"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/api"
) )
func TestParseXAlignedFromResult(t *testing.T) { func TestParseXAlignedFromResult(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
part string part string
expectedResult model.AuthResultResult expectedResult api.AuthResultResult
expectedDetail string expectedDetail string
}{ }{
{ {
name: "x-aligned-from pass with details", name: "x-aligned-from pass with details",
part: "x-aligned-from=pass (Address match)", part: "x-aligned-from=pass (Address match)",
expectedResult: model.AuthResultResultPass, expectedResult: api.AuthResultResultPass,
expectedDetail: "pass (Address match)", expectedDetail: "pass (Address match)",
}, },
{ {
name: "x-aligned-from fail with reason", name: "x-aligned-from fail with reason",
part: "x-aligned-from=fail (Address mismatch)", part: "x-aligned-from=fail (Address mismatch)",
expectedResult: model.AuthResultResultFail, expectedResult: api.AuthResultResultFail,
expectedDetail: "fail (Address mismatch)", expectedDetail: "fail (Address mismatch)",
}, },
{ {
name: "x-aligned-from pass minimal", name: "x-aligned-from pass minimal",
part: "x-aligned-from=pass", part: "x-aligned-from=pass",
expectedResult: model.AuthResultResultPass, expectedResult: api.AuthResultResultPass,
expectedDetail: "pass", expectedDetail: "pass",
}, },
{ {
name: "x-aligned-from neutral", name: "x-aligned-from neutral",
part: "x-aligned-from=neutral (No alignment check performed)", part: "x-aligned-from=neutral (No alignment check performed)",
expectedResult: model.AuthResultResultNeutral, expectedResult: api.AuthResultResultNeutral,
expectedDetail: "neutral (No alignment check performed)", expectedDetail: "neutral (No alignment check performed)",
}, },
{ {
name: "x-aligned-from none", name: "x-aligned-from none",
part: "x-aligned-from=none", part: "x-aligned-from=none",
expectedResult: model.AuthResultResultNone, expectedResult: api.AuthResultResultNone,
expectedDetail: "none", expectedDetail: "none",
}, },
} }
analyzer := NewAuthenticationAnalyzer("") analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -88,34 +88,34 @@ func TestParseXAlignedFromResult(t *testing.T) {
func TestCalculateXAlignedFromScore(t *testing.T) { func TestCalculateXAlignedFromScore(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
result *model.AuthResult result *api.AuthResult
expectedScore int expectedScore int
}{ }{
{ {
name: "pass result gives positive score", name: "pass result gives positive score",
result: &model.AuthResult{ result: &api.AuthResult{
Result: model.AuthResultResultPass, Result: api.AuthResultResultPass,
}, },
expectedScore: 100, expectedScore: 100,
}, },
{ {
name: "fail result gives zero score", name: "fail result gives zero score",
result: &model.AuthResult{ result: &api.AuthResult{
Result: model.AuthResultResultFail, Result: api.AuthResultResultFail,
}, },
expectedScore: 0, expectedScore: 0,
}, },
{ {
name: "neutral result gives zero score", name: "neutral result gives zero score",
result: &model.AuthResult{ result: &api.AuthResult{
Result: model.AuthResultResultNeutral, Result: api.AuthResultResultNeutral,
}, },
expectedScore: 0, expectedScore: 0,
}, },
{ {
name: "none result gives zero score", name: "none result gives zero score",
result: &model.AuthResult{ result: &api.AuthResult{
Result: model.AuthResultResultNone, Result: api.AuthResultResultNone,
}, },
expectedScore: 0, expectedScore: 0,
}, },
@ -126,11 +126,11 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
}, },
} }
analyzer := NewAuthenticationAnalyzer("") analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
results := &model.AuthenticationResults{ results := &api.AuthenticationResults{
XAlignedFrom: tt.result, XAlignedFrom: tt.result,
} }

View file

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

View file

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

View file

@ -32,8 +32,7 @@ import (
"time" "time"
"unicode" "unicode"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/utils"
"golang.org/x/net/html" "golang.org/x/net/html"
) )
@ -729,16 +728,16 @@ func (c *ContentAnalyzer) normalizeText(text string) string {
} }
// GenerateContentAnalysis creates structured content analysis from results // 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 { if results == nil {
return nil return nil
} }
analysis := &model.ContentAnalysis{ analysis := &api.ContentAnalysis{
HasHtml: utils.PtrTo(results.HTMLContent != ""), HasHtml: api.PtrTo(results.HTMLContent != ""),
HasPlaintext: utils.PtrTo(results.TextContent != ""), HasPlaintext: api.PtrTo(results.TextContent != ""),
HasUnsubscribeLink: utils.PtrTo(results.HasUnsubscribe), HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
UnsubscribeMethods: &[]model.ContentAnalysisUnsubscribeMethods{}, UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{},
} }
// Calculate text-to-image ratio (inverse of image-to-text) // Calculate text-to-image ratio (inverse of image-to-text)
@ -751,16 +750,16 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
} }
// Build HTML issues // Build HTML issues
htmlIssues := []model.ContentIssue{} htmlIssues := []api.ContentIssue{}
// Add HTML parsing errors // Add HTML parsing errors
if !results.HTMLValid && len(results.HTMLErrors) > 0 { if !results.HTMLValid && len(results.HTMLErrors) > 0 {
for _, errMsg := range results.HTMLErrors { for _, errMsg := range results.HTMLErrors {
htmlIssues = append(htmlIssues, model.ContentIssue{ htmlIssues = append(htmlIssues, api.ContentIssue{
Type: model.BrokenHtml, Type: api.BrokenHtml,
Severity: model.ContentIssueSeverityHigh, Severity: api.ContentIssueSeverityHigh,
Message: errMsg, 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 { if missingAltCount > 0 {
htmlIssues = append(htmlIssues, model.ContentIssue{ htmlIssues = append(htmlIssues, api.ContentIssue{
Type: model.MissingAlt, Type: api.MissingAlt,
Severity: model.ContentIssueSeverityMedium, Severity: api.ContentIssueSeverityMedium,
Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount), 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 // Add excessive images issue
if results.ImageTextRatio > 10.0 { if results.ImageTextRatio > 10.0 {
htmlIssues = append(htmlIssues, model.ContentIssue{ htmlIssues = append(htmlIssues, api.ContentIssue{
Type: model.ExcessiveImages, Type: api.ExcessiveImages,
Severity: model.ContentIssueSeverityMedium, Severity: api.ContentIssueSeverityMedium,
Message: "Email is excessively image-heavy", 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 // Add suspicious URL issues
for _, suspURL := range results.SuspiciousURLs { for _, suspURL := range results.SuspiciousURLs {
htmlIssues = append(htmlIssues, model.ContentIssue{ htmlIssues = append(htmlIssues, api.ContentIssue{
Type: model.SuspiciousLink, Type: api.SuspiciousLink,
Severity: model.ContentIssueSeverityHigh, Severity: api.ContentIssueSeverityHigh,
Message: "Suspicious URL detected", Message: "Suspicious URL detected",
Location: &suspURL, 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 // Add harmful HTML tag issues
for _, harmfulIssue := range results.HarmfullIssues { for _, harmfulIssue := range results.HarmfullIssues {
htmlIssues = append(htmlIssues, model.ContentIssue{ htmlIssues = append(htmlIssues, api.ContentIssue{
Type: model.DangerousHtml, Type: api.DangerousHtml,
Severity: model.ContentIssueSeverityCritical, Severity: api.ContentIssueSeverityCritical,
Message: harmfulIssue, 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) // Add general content issues (like external stylesheets)
for _, contentIssue := range results.ContentIssues { for _, contentIssue := range results.ContentIssues {
htmlIssues = append(htmlIssues, model.ContentIssue{ htmlIssues = append(htmlIssues, api.ContentIssue{
Type: model.BrokenHtml, Type: api.BrokenHtml,
Severity: model.ContentIssueSeverityLow, Severity: api.ContentIssueSeverityLow,
Message: contentIssue, 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 // Convert links
if len(results.Links) > 0 { 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 { for _, link := range results.Links {
status := model.Valid status := api.Valid
if link.Status >= 400 { if link.Status >= 400 {
status = model.Broken status = api.Broken
} else if !link.IsSafe { } else if !link.IsSafe {
status = model.Suspicious status = api.Suspicious
} else if link.Warning != "" { } else if link.Warning != "" {
status = model.Timeout status = api.Timeout
} }
apiLink := model.LinkCheck{ apiLink := api.LinkCheck{
Url: link.URL, Url: link.URL,
Status: status, Status: status,
} }
if link.Status > 0 { if link.Status > 0 {
apiLink.HttpCode = utils.PtrTo(link.Status) apiLink.HttpCode = api.PtrTo(link.Status)
} }
// Check if it's a URL shortener // Check if it's a URL shortener
parsedURL, err := url.Parse(link.URL) parsedURL, err := url.Parse(link.URL)
if err == nil { if err == nil {
isShortened := c.isSuspiciousURL(link.URL, parsedURL) isShortened := c.isSuspiciousURL(link.URL, parsedURL)
apiLink.IsShortened = utils.PtrTo(isShortened) apiLink.IsShortened = api.PtrTo(isShortened)
} }
links = append(links, apiLink) links = append(links, apiLink)
@ -864,9 +863,9 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
// Convert images // Convert images
if len(results.Images) > 0 { 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 { for _, img := range results.Images {
apiImg := model.ImageCheck{ apiImg := api.ImageCheck{
HasAlt: img.HasAlt, HasAlt: img.HasAlt,
} }
if img.Src != "" { if img.Src != "" {
@ -876,7 +875,7 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
apiImg.AltText = &img.AltText apiImg.AltText = &img.AltText
} }
// Simple heuristic: tracking pixels are typically 1x1 // Simple heuristic: tracking pixels are typically 1x1
apiImg.IsTrackingPixel = utils.PtrTo(false) apiImg.IsTrackingPixel = api.PtrTo(false)
images = append(images, apiImg) images = append(images, apiImg)
} }
@ -885,19 +884,19 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *mode
// Unsubscribe methods // Unsubscribe methods
if results.HasUnsubscribe { if results.HasUnsubscribe {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.Link) *analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Link)
} }
for _, url := range c.listUnsubscribeURLs { for _, url := range c.listUnsubscribeURLs {
if strings.HasPrefix(url, "mailto:") { 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:") { } 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 { if slices.Contains(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.OneClick) *analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.OneClick)
} }
return analysis return analysis

View file

@ -24,7 +24,7 @@ package analyzer
import ( import (
"time" "time"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/api"
) )
// DNSAnalyzer analyzes DNS records for email domains // 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 // 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 // Extract domain from From address
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" { if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
return &model.DNSResults{ return &api.DNSResults{
Errors: &[]string{"Unable to extract domain from email"}, Errors: &[]string{"Unable to extract domain from email"},
} }
} }
fromDomain := *headersResults.DomainAlignment.FromDomain fromDomain := *headersResults.DomainAlignment.FromDomain
results := &model.DNSResults{ results := &api.DNSResults{
FromDomain: fromDomain, FromDomain: fromDomain,
RpDomain: headersResults.DomainAlignment.ReturnPathDomain, 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 // SPF validates the MAIL FROM command, which corresponds to Return-Path
results.SpfRecords = d.checkSPFRecords(spfDomain) results.SpfRecords = d.checkSPFRecords(spfDomain)
// Check DKIM records by parsing DKIM-Signature headers directly // Check DKIM records (from authentication results)
for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) { // DKIM can be for any domain, but typically the From domain
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector) if authResults != nil && authResults.Dkim != nil {
if dkimRecord != nil { for _, dkim := range *authResults.Dkim {
if results.DkimRecords == nil { if dkim.Domain != nil && dkim.Selector != nil {
results.DkimRecords = new([]model.DKIMRecord) 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 // AnalyzeDomainOnly performs DNS validation for a domain without email context
// This is useful for checking domain configuration without sending an actual email // This is useful for checking domain configuration without sending an actual email
func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults { func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
results := &model.DNSResults{ results := &api.DNSResults{
FromDomain: domain, FromDomain: domain,
} }
@ -150,7 +155,7 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
// CalculateDomainOnlyScore calculates the DNS score for domain-only tests // CalculateDomainOnlyScore calculates the DNS score for domain-only tests
// Returns a score from 0-100 where higher is better // Returns a score from 0-100 where higher is better
// This version excludes PTR and DKIM checks since they require email context // 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 { if results == nil {
return 0, "" return 0, ""
} }
@ -192,7 +197,7 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int,
// CalculateDNSScore calculates the DNS score from records results // CalculateDNSScore calculates the DNS score from records results
// Returns a score from 0-100 where higher is better // Returns a score from 0-100 where higher is better
// senderIP is the original sender IP address used for FCrDNS verification // 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 { if results == nil {
return 0, "" return 0, ""
} }

View file

@ -27,12 +27,11 @@ import (
"regexp" "regexp"
"strings" "strings"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/utils"
) )
// checkBIMIRecord looks up and validates BIMI record for a domain and selector // 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 // BIMI records are at: selector._bimi.domain
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, 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) txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
if err != nil { if err != nil {
return &model.BIMIRecord{ return &api.BIMIRecord{
Selector: selector, Selector: selector,
Domain: domain, Domain: domain,
Valid: false, 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 { if len(txtRecords) == 0 {
return &model.BIMIRecord{ return &api.BIMIRecord{
Selector: selector, Selector: selector,
Domain: domain, Domain: domain,
Valid: false, 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) // Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
if !d.validateBIMI(bimiRecord) { if !d.validateBIMI(bimiRecord) {
return &model.BIMIRecord{ return &api.BIMIRecord{
Selector: selector, Selector: selector,
Domain: domain, Domain: domain,
Record: &bimiRecord, Record: &bimiRecord,
LogoUrl: &logoURL, LogoUrl: &logoURL,
VmcUrl: &vmcURL, VmcUrl: &vmcURL,
Valid: false, Valid: false,
Error: utils.PtrTo("BIMI record appears malformed"), Error: api.PtrTo("BIMI record appears malformed"),
} }
} }
return &model.BIMIRecord{ return &api.BIMIRecord{
Selector: selector, Selector: selector,
Domain: domain, Domain: domain,
Record: &bimiRecord, Record: &bimiRecord,

View file

@ -26,44 +26,11 @@ import (
"fmt" "fmt"
"strings" "strings"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/utils"
) )
// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header. // checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
type DKIMHeader struct { func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
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 {
// DKIM records are at: selector._domainkey.domain // DKIM records are at: selector._domainkey.domain
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, 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) txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
if err != nil { if err != nil {
return &model.DKIMRecord{ return &api.DKIMRecord{
Selector: selector, Selector: selector,
Domain: domain, Domain: domain,
Valid: false, 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 { if len(txtRecords) == 0 {
return &model.DKIMRecord{ return &api.DKIMRecord{
Selector: selector, Selector: selector,
Domain: domain, Domain: domain,
Valid: false, 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) // Basic validation - should contain "v=DKIM1" and "p=" (public key)
if !d.validateDKIM(dkimRecord) { if !d.validateDKIM(dkimRecord) {
return &model.DKIMRecord{ return &api.DKIMRecord{
Selector: selector, Selector: selector,
Domain: domain, Domain: domain,
Record: utils.PtrTo(dkimRecord), Record: api.PtrTo(dkimRecord),
Valid: false, Valid: false,
Error: utils.PtrTo("DKIM record appears malformed"), Error: api.PtrTo("DKIM record appears malformed"),
} }
} }
return &model.DKIMRecord{ return &api.DKIMRecord{
Selector: selector, Selector: selector,
Domain: domain, Domain: domain,
Record: &dkimRecord, Record: &dkimRecord,
@ -127,7 +94,7 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool {
return true 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 // DKIM provides strong email authentication
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 { if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
hasValidDKIM := false hasValidDKIM := false

View file

@ -26,220 +26,6 @@ import (
"time" "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) { func TestValidateDKIM(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

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

View file

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

View file

@ -24,7 +24,7 @@ package analyzer
import ( import (
"context" "context"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/api"
) )
// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA) // 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 // 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 { if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
// 50 points for having PTR records // 50 points for having PTR records
score += 50 score += 50

View file

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

View file

@ -27,34 +27,33 @@ import (
"regexp" "regexp"
"strings" "strings"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/utils"
) )
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives // checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]model.SPFRecord {