Compare commits
3 commits
master
...
renovate/n
| Author | SHA1 | Date | |
|---|---|---|---|
| def0596d92 | |||
| c7dc3577e4 | |||
| ff013fe694 |
98 changed files with 3136 additions and 14717 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -26,5 +26,5 @@ logs/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# OpenAPI generated files
|
# OpenAPI generated files
|
||||||
|
internal/api/models.gen.go
|
||||||
internal/api/server.gen.go
|
internal/api/server.gen.go
|
||||||
internal/model/types.gen.go
|
|
||||||
|
|
|
||||||
11
Dockerfile
11
Dockerfile
|
|
@ -49,7 +49,6 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
|
||||||
perl-crypt-openssl-random \
|
perl-crypt-openssl-random \
|
||||||
perl-crypt-openssl-verify \
|
perl-crypt-openssl-verify \
|
||||||
perl-crypt-openssl-x509 \
|
perl-crypt-openssl-x509 \
|
||||||
perl-cryptx \
|
|
||||||
perl-dbd-sqlite \
|
perl-dbd-sqlite \
|
||||||
perl-dbi \
|
perl-dbi \
|
||||||
perl-email-address-xs \
|
perl-email-address-xs \
|
||||||
|
|
@ -76,7 +75,6 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
|
||||||
ln -s /usr/bin/ld /bin/ld
|
ln -s /usr/bin/ld /bin/ld
|
||||||
|
|
||||||
RUN cpanm --notest Mail::SPF && \
|
RUN cpanm --notest Mail::SPF && \
|
||||||
cpanm --notest Mail::DKIM && \
|
|
||||||
cpanm --notest Mail::Milter::Authentication
|
cpanm --notest Mail::Milter::Authentication
|
||||||
|
|
||||||
RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milter-0.4.0.tar.gz && \
|
RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milter-0.4.0.tar.gz && \
|
||||||
|
|
@ -102,7 +100,6 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
|
||||||
perl-crypt-openssl-random \
|
perl-crypt-openssl-random \
|
||||||
perl-crypt-openssl-verify \
|
perl-crypt-openssl-verify \
|
||||||
perl-crypt-openssl-x509 \
|
perl-crypt-openssl-x509 \
|
||||||
perl-cryptx \
|
|
||||||
perl-dbd-sqlite \
|
perl-dbd-sqlite \
|
||||||
perl-dbi \
|
perl-dbi \
|
||||||
perl-email-address-xs \
|
perl-email-address-xs \
|
||||||
|
|
@ -173,13 +170,7 @@ RUN chmod +x /entrypoint.sh
|
||||||
EXPOSE 25 8080
|
EXPOSE 25 8080
|
||||||
|
|
||||||
# Default configuration
|
# Default configuration
|
||||||
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
|
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net
|
||||||
HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \
|
|
||||||
HAPPYDELIVER_DOMAIN=happydeliver.local \
|
|
||||||
HAPPYDELIVER_ADDRESS_PREFIX=test- \
|
|
||||||
HAPPYDELIVER_DNS_TIMEOUT=5s \
|
|
||||||
HAPPYDELIVER_HTTP_TIMEOUT=10s \
|
|
||||||
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"]
|
||||||
|
|
|
||||||
46
README.md
46
README.md
|
|
@ -166,24 +166,7 @@ The server will start on `http://localhost:8080` by default.
|
||||||
It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ...
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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: "-"
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
1167
api/openapi.yaml
1167
api/openapi.yaml
File diff suppressed because it is too large
Load diff
1319
api/schemas.yaml
1319
api/schemas.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,18 +1,4 @@
|
||||||
services:
|
services:
|
||||||
unbound:
|
|
||||||
image: alpinelinux/unbound
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
configs:
|
|
||||||
- source: unbound_conf
|
|
||||||
target: /etc/unbound/unbound.conf
|
|
||||||
uid: "100"
|
|
||||||
gid: "101"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
ipv4_address: 172.28.0.53
|
|
||||||
|
|
||||||
happydeliver:
|
happydeliver:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -38,41 +24,8 @@ services:
|
||||||
# Log files
|
# Log files
|
||||||
- ./logs:/var/log/happydeliver
|
- ./logs:/var/log/happydeliver
|
||||||
|
|
||||||
dns:
|
|
||||||
- 172.28.0.53
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
configs:
|
|
||||||
unbound_conf:
|
|
||||||
content: |
|
|
||||||
server:
|
|
||||||
verbosity: 1
|
|
||||||
interface: 0.0.0.0
|
|
||||||
port: 53
|
|
||||||
do-ip4: yes
|
|
||||||
do-ip6: no
|
|
||||||
do-udp: yes
|
|
||||||
do-tcp: yes
|
|
||||||
|
|
||||||
access-control: 127.0.0.0/8 allow
|
|
||||||
access-control: 172.28.0.0/24 allow
|
|
||||||
|
|
||||||
# Short cache for a testing resolver
|
|
||||||
cache-max-ttl: 60
|
|
||||||
|
|
||||||
# Buffers: let the system decide
|
|
||||||
so-sndbuf: 0
|
|
||||||
so-rcvbuf: 0
|
|
||||||
|
|
||||||
# Trust anchor (static, ships with the image)
|
|
||||||
trust-anchor-file: "/etc/unbound/root.key"
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
logs:
|
logs:
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 172.28.0.0/24
|
|
||||||
|
|
|
||||||
|
|
@ -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:**
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,6 @@
|
||||||
|
|
||||||
"PTR" : {},
|
"PTR" : {},
|
||||||
|
|
||||||
"TLS" : {},
|
|
||||||
|
|
||||||
"SenderID" : {
|
"SenderID" : {
|
||||||
"hide_none" : 1
|
"hide_none" : 1
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ myhostname = __HOSTNAME__
|
||||||
mydomain = __DOMAIN__
|
mydomain = __DOMAIN__
|
||||||
myorigin = $mydomain
|
myorigin = $mydomain
|
||||||
inet_interfaces = all
|
inet_interfaces = all
|
||||||
inet_protocols = all
|
inet_protocols = ipv4
|
||||||
|
|
||||||
# Recipient settings
|
# Recipient settings
|
||||||
mydestination = localhost.$mydomain, localhost
|
mydestination = localhost.$mydomain, localhost
|
||||||
|
|
@ -36,8 +36,5 @@ smtpd_recipient_restrictions =
|
||||||
permit_mynetworks,
|
permit_mynetworks,
|
||||||
reject_unauth_destination
|
reject_unauth_destination
|
||||||
|
|
||||||
# TLS - record the negotiated cipher/protocol in the Received: header
|
|
||||||
smtpd_tls_received_header = yes
|
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
debug_peer_level = 2
|
debug_peer_level = 2
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@
|
||||||
# SMTP service
|
# SMTP service
|
||||||
smtp inet n - n - - smtpd
|
smtp inet n - n - - smtpd
|
||||||
|
|
||||||
# TLS session cache and PRNG manager (required for STARTTLS)
|
|
||||||
tlsmgr unix - - n 1000? 1 tlsmgr
|
|
||||||
|
|
||||||
# Pickup service
|
# Pickup service
|
||||||
pickup unix n - n 60 1 pickup
|
pickup unix n - n 60 1 pickup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
44
go.mod
44
go.mod
|
|
@ -1,15 +1,15 @@
|
||||||
module git.happydns.org/happyDeliver
|
module git.happydns.org/happyDeliver
|
||||||
|
|
||||||
go 1.25.0
|
go 1.24.6
|
||||||
|
|
||||||
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.138.0
|
github.com/getkin/kin-openapi v0.133.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/oapi-codegen/runtime v1.4.0
|
github.com/oapi-codegen/runtime v1.1.2
|
||||||
golang.org/x/net v0.55.0
|
golang.org/x/net v0.50.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
|
|
@ -36,7 +36,7 @@ require (
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
|
@ -50,32 +50,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.12 // 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/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
||||||
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
|
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
||||||
github.com/speakeasy-api/openapi v1.19.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.uber.org/mock v0.6.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.52.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/mod v0.35.0 // indirect
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.44.0 // indirect
|
golang.org/x/tools v0.41.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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
94
go.sum
94
go.sum
|
|
@ -1,5 +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/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 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
|
|
@ -22,13 +22,10 @@ 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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
|
||||||
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=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||||
|
|
@ -41,12 +38,12 @@ 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.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4=
|
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||||
github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
|
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.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||||
|
|
@ -93,8 +90,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
|
@ -133,14 +130,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.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4=
|
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
|
||||||
github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec=
|
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||||
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.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M=
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||||
github.com/oasdiff/yaml3 v0.0.12/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=
|
||||||
|
|
@ -157,25 +154,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/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
|
||||||
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/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=
|
||||||
|
|
@ -200,26 +194,18 @@ 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/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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-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=
|
||||||
|
|
@ -227,13 +213,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-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=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|
@ -249,21 +235,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-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=
|
||||||
|
|
|
||||||
|
|
@ -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, 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,79 +346,37 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform blacklist check using analyzer
|
// Perform blacklist check using analyzer
|
||||||
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
checks, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, 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,
|
Checks: checks,
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -202,9 +202,6 @@ func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyze
|
||||||
if dns.DmarcRecord.SubdomainPolicy != nil {
|
if dns.DmarcRecord.SubdomainPolicy != nil {
|
||||||
fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy)
|
fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy)
|
||||||
}
|
}
|
||||||
if dns.DmarcRecord.NonexistentSubdomainPolicy != nil {
|
|
||||||
fmt.Fprintf(writer, ", Non-Existent Subdomain Policy: %s", *dns.DmarcRecord.NonexistentSubdomainPolicy)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(writer)
|
fmt.Fprintln(writer)
|
||||||
if dns.DmarcRecord.Record != nil {
|
if dns.DmarcRecord.Record != nil {
|
||||||
fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record)
|
fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -74,7 +67,6 @@ type AnalysisConfig struct {
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -123,28 +121,22 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, strin
|
||||||
return dnsResults, score, grade
|
return dnsResults, score, grade
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
|
// CheckBlacklistIP checks a single IP address against DNS blacklists
|
||||||
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.BlacklistCheck, int, int, string, error) {
|
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int, string, error) {
|
||||||
// Check the IP against all configured RBLs
|
// Check the IP against all configured RBLs
|
||||||
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, 0, 0, "", err
|
return nil, 0, 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate score using the existing function
|
// Calculate score using the existing function
|
||||||
// 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)
|
return checks, listedCount, score, grade, nil
|
||||||
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)
|
|
||||||
if err != nil {
|
|
||||||
whitelists = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return checks, whitelists, listedCount, score, grade, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -140,59 +138,39 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
||||||
results.XAlignedFrom = a.parseXAlignedFromResult(part)
|
results.XAlignedFrom = a.parseXAlignedFromResult(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse x-ptr
|
|
||||||
if strings.HasPrefix(part, "x-ptr=") {
|
|
||||||
if results.XPtr == nil {
|
|
||||||
results.XPtr = a.parseXPtrResult(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse x-tls
|
|
||||||
if strings.HasPrefix(part, "x-tls=") {
|
|
||||||
if results.XTls == nil {
|
|
||||||
results.XTls = a.parseXTLSResult(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
score += 5 * a.calculateXAlignedFromScore(results) / 100
|
|
||||||
|
|
||||||
// Penalty-only: X-TLS / transport encryption (-10 points when not encrypted)
|
|
||||||
score += 10 * a.calculateXTLSScore(results) / 100
|
|
||||||
|
|
||||||
// Ensure score doesn't exceed 100
|
// Ensure score doesn't exceed 100
|
||||||
if score > 100 {
|
if score > 100 {
|
||||||
score = 100
|
score = 100
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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: 90, // SPF=30 + DKIM=30 + DMARC=30
|
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: 60, // SPF=30 + DKIM=30
|
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: 30, // SPF=0 + DKIM=30
|
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: 5, // 30 * 17 / 100 = 5
|
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: 40, // SPF (30) + 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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -25,37 +25,36 @@ 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: no impact
|
// pass: positive contribution
|
||||||
return 0
|
return 100
|
||||||
case model.AuthResultResultFail:
|
case api.AuthResultResultFail:
|
||||||
// fail: negative contribution
|
// fail: negative contribution
|
||||||
return -100
|
return 0
|
||||||
default:
|
default:
|
||||||
// neutral, none, etc.: no impact
|
// neutral, none, etc.: no impact
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -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 no penalty",
|
name: "pass result gives positive score",
|
||||||
result: &model.AuthResult{
|
result: &api.AuthResult{
|
||||||
Result: model.AuthResultResultPass,
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
expectedScore: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fail result gives zero score",
|
||||||
|
result: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultFail,
|
||||||
},
|
},
|
||||||
expectedScore: 0,
|
expectedScore: 0,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "fail result gives full penalty",
|
|
||||||
result: &model.AuthResult{
|
|
||||||
Result: model.AuthResultResultFail,
|
|
||||||
},
|
|
||||||
expectedScore: -100,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2025-2026 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
// parseXPtrResult parses the x-ptr result from Authentication-Results.
|
|
||||||
// Example: x-ptr=fail smtp.helo=relay.example.org policy.ptr=mail.example.com
|
|
||||||
func (a *AuthenticationAnalyzer) parseXPtrResult(part string) *model.XPtrResult {
|
|
||||||
result := &model.XPtrResult{}
|
|
||||||
|
|
||||||
// Extract result (pass, fail, none, temperror, permerror)
|
|
||||||
re := regexp.MustCompile(`x-ptr=(\w+)`)
|
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
|
||||||
resultStr := strings.ToLower(matches[1])
|
|
||||||
result.Result = model.XPtrResultResult(resultStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract announced HELO hostname (smtp.helo)
|
|
||||||
heloRe := regexp.MustCompile(`smtp\.helo=([^\s;()]+)`)
|
|
||||||
if matches := heloRe.FindStringSubmatch(part); len(matches) > 1 {
|
|
||||||
helo := matches[1]
|
|
||||||
result.Helo = &helo
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract reverse DNS hostname (policy.ptr)
|
|
||||||
ptrRe := regexp.MustCompile(`policy\.ptr=([^\s;()]+)`)
|
|
||||||
if matches := ptrRe.FindStringSubmatch(part); len(matches) > 1 {
|
|
||||||
ptr := matches[1]
|
|
||||||
result.Ptr = &ptr
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-ptr="))
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2025-2026 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseXPtrResult(t *testing.T) {
|
|
||||||
a := NewAuthenticationAnalyzer("receiver.com")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
part string
|
|
||||||
expectedResult model.XPtrResultResult
|
|
||||||
expectedHelo *string
|
|
||||||
expectedPtr *string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "x-ptr fail with helo and ptr",
|
|
||||||
part: "x-ptr=fail smtp.helo=relay.example.org policy.ptr=mail.example.com",
|
|
||||||
expectedResult: model.XPtrResultResultFail,
|
|
||||||
expectedHelo: utils.PtrTo("relay.example.org"),
|
|
||||||
expectedPtr: utils.PtrTo("mail.example.com"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "x-ptr pass",
|
|
||||||
part: "x-ptr=pass smtp.helo=mail.example.com policy.ptr=mail.example.com",
|
|
||||||
expectedResult: model.XPtrResultResultPass,
|
|
||||||
expectedHelo: utils.PtrTo("mail.example.com"),
|
|
||||||
expectedPtr: utils.PtrTo("mail.example.com"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "x-ptr none without ptr",
|
|
||||||
part: "x-ptr=none smtp.helo=relay.example.org",
|
|
||||||
expectedResult: model.XPtrResultResultNone,
|
|
||||||
expectedHelo: utils.PtrTo("relay.example.org"),
|
|
||||||
expectedPtr: nil,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := a.parseXPtrResult(tt.part)
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("expected non-nil result")
|
|
||||||
}
|
|
||||||
if result.Result != tt.expectedResult {
|
|
||||||
t.Errorf("Result = %q, want %q", result.Result, tt.expectedResult)
|
|
||||||
}
|
|
||||||
if !equalStrPtr(result.Helo, tt.expectedHelo) {
|
|
||||||
t.Errorf("Helo = %v, want %v", result.Helo, tt.expectedHelo)
|
|
||||||
}
|
|
||||||
if !equalStrPtr(result.Ptr, tt.expectedPtr) {
|
|
||||||
t.Errorf("Ptr = %v, want %v", result.Ptr, tt.expectedPtr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2025 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
// parseXTLSResult parses the x-tls result from Authentication-Results.
|
|
||||||
// Example: x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256
|
|
||||||
func (a *AuthenticationAnalyzer) parseXTLSResult(part string) *model.AuthResult {
|
|
||||||
result := &model.AuthResult{}
|
|
||||||
|
|
||||||
// Extract result (pass, fail, none, ...)
|
|
||||||
re := regexp.MustCompile(`x-tls=(\w+)`)
|
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
|
||||||
result.Result = model.AuthResultResult(strings.ToLower(matches[1]))
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Details = utils.PtrTo(formatTLSDetails(
|
|
||||||
submatch(part, `smtp\.version=([^\s;()]+)`),
|
|
||||||
submatch(part, `smtp\.cipher=([^\s;()]+)`),
|
|
||||||
submatch(part, `smtp\.bits=(\d+)`),
|
|
||||||
))
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateXTLSScore returns a penalty for a negative transport-TLS result.
|
|
||||||
// pass (or absent) does not alter the score; any other result is penalized.
|
|
||||||
func (a *AuthenticationAnalyzer) calculateXTLSScore(results *model.AuthenticationResults) (score int) {
|
|
||||||
if results.XTls != nil {
|
|
||||||
switch results.XTls.Result {
|
|
||||||
case model.AuthResultResultPass:
|
|
||||||
// pass: don't alter the score
|
|
||||||
default:
|
|
||||||
return -100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReconcileXTLS fills in the x-tls result from the inbound connection's parsed TLS
|
|
||||||
// information when no x-tls Authentication-Results header was present. The inbound
|
|
||||||
// connection is the most recent hop (index 0) of the received chain.
|
|
||||||
func (a *AuthenticationAnalyzer) ReconcileXTLS(results *model.AuthenticationResults, chain *[]model.ReceivedHop) {
|
|
||||||
if results == nil || results.XTls != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if chain == nil || len(*chain) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
inbound := (*chain)[0]
|
|
||||||
switch {
|
|
||||||
case inbound.Tls != nil:
|
|
||||||
// Full TLS parenthetical present (smtpd_tls_received_header = yes).
|
|
||||||
var version, cipher, bits string
|
|
||||||
if inbound.Tls.Version != nil {
|
|
||||||
version = *inbound.Tls.Version
|
|
||||||
}
|
|
||||||
if inbound.Tls.Cipher != nil {
|
|
||||||
cipher = *inbound.Tls.Cipher
|
|
||||||
}
|
|
||||||
if inbound.Tls.Bits != nil {
|
|
||||||
bits = fmt.Sprintf("%d", *inbound.Tls.Bits)
|
|
||||||
}
|
|
||||||
results.XTls = &model.AuthResult{
|
|
||||||
Result: model.AuthResultResultPass,
|
|
||||||
Details: utils.PtrTo(formatTLSDetails(version, cipher, bits)),
|
|
||||||
}
|
|
||||||
|
|
||||||
case protocolIndicatesTLS(inbound.With):
|
|
||||||
// No TLS parenthetical (smtpd_tls_received_header may be disabled), but the
|
|
||||||
// transport keyword (ESMTPS, ESMTPSA, ...) tells us the session was encrypted.
|
|
||||||
// We just don't have the cipher details.
|
|
||||||
results.XTls = &model.AuthResult{
|
|
||||||
Result: model.AuthResultResultPass,
|
|
||||||
Details: utils.PtrTo(fmt.Sprintf("Encrypted connection (%s); cipher details unavailable", *inbound.With)),
|
|
||||||
}
|
|
||||||
|
|
||||||
case inbound.With != nil:
|
|
||||||
// A plaintext transport keyword (SMTP, ESMTP, ESMTPA, ...) is positive
|
|
||||||
// evidence the inbound connection was not encrypted.
|
|
||||||
results.XTls = &model.AuthResult{
|
|
||||||
Result: model.AuthResultResultNone,
|
|
||||||
Details: utils.PtrTo(fmt.Sprintf("Inbound connection was not encrypted (%s)", *inbound.With)),
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Neither TLS details nor a transport keyword: we cannot tell whether the
|
|
||||||
// connection was encrypted. Leave x-tls unset rather than wrongly penalize.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// protocolIndicatesTLS reports whether an SMTP "with" transport keyword denotes a
|
|
||||||
// TLS-encrypted session. Per RFC 3848 the keyword gains a trailing "S" when STARTTLS
|
|
||||||
// (or implicit TLS) was negotiated: ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA, UTF8SMTPS...
|
|
||||||
// The plaintext variants end in "P" (SMTP, ESMTP, LMTP) or "A" (ESMTPA, LMTPA).
|
|
||||||
func protocolIndicatesTLS(with *string) bool {
|
|
||||||
if with == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
p := strings.ToUpper(strings.TrimSpace(*with))
|
|
||||||
return strings.HasSuffix(p, "S") || strings.HasSuffix(p, "SA")
|
|
||||||
}
|
|
||||||
|
|
||||||
// submatch returns the first capture group of pattern in s, or "".
|
|
||||||
func submatch(s, pattern string) string {
|
|
||||||
if matches := regexp.MustCompile(pattern).FindStringSubmatch(s); len(matches) > 1 {
|
|
||||||
return matches[1]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatTLSDetails builds a human-readable summary of the TLS parameters.
|
|
||||||
func formatTLSDetails(version, cipher, bits string) string {
|
|
||||||
var parts []string
|
|
||||||
if version != "" {
|
|
||||||
parts = append(parts, version)
|
|
||||||
}
|
|
||||||
if cipher != "" {
|
|
||||||
parts = append(parts, "cipher "+cipher)
|
|
||||||
}
|
|
||||||
if bits != "" {
|
|
||||||
parts = append(parts, bits+" bits")
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ", ")
|
|
||||||
}
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2025 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseXTLSResult(t *testing.T) {
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
|
||||||
|
|
||||||
result := analyzer.parseXTLSResult("x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256")
|
|
||||||
|
|
||||||
if result.Result != model.AuthResultResultPass {
|
|
||||||
t.Errorf("Result = %v, want pass", result.Result)
|
|
||||||
}
|
|
||||||
if result.Details == nil {
|
|
||||||
t.Fatal("Details should not be nil")
|
|
||||||
}
|
|
||||||
for _, want := range []string{"TLSv1.3", "TLS_AES_256_GCM_SHA384", "256 bits"} {
|
|
||||||
if !strings.Contains(*result.Details, want) {
|
|
||||||
t.Errorf("Details %q should contain %q", *result.Details, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculateXTLSScore(t *testing.T) {
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
xtls *model.AuthResult
|
|
||||||
score int
|
|
||||||
}{
|
|
||||||
{"nil", nil, 0},
|
|
||||||
{"pass", &model.AuthResult{Result: model.AuthResultResultPass}, 0},
|
|
||||||
{"none", &model.AuthResult{Result: model.AuthResultResultNone}, -100},
|
|
||||||
{"fail", &model.AuthResult{Result: model.AuthResultResultFail}, -100},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
results := &model.AuthenticationResults{XTls: tt.xtls}
|
|
||||||
if got := analyzer.calculateXTLSScore(results); got != tt.score {
|
|
||||||
t.Errorf("calculateXTLSScore = %d, want %d", got, tt.score)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReconcileXTLS(t *testing.T) {
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
|
||||||
|
|
||||||
t.Run("keeps existing x-tls header result", func(t *testing.T) {
|
|
||||||
existing := &model.AuthResult{Result: model.AuthResultResultFail}
|
|
||||||
results := &model.AuthenticationResults{XTls: existing}
|
|
||||||
chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{Version: utils.PtrTo("TLSv1.3")}}}
|
|
||||||
analyzer.ReconcileXTLS(results, chain)
|
|
||||||
if results.XTls != existing {
|
|
||||||
t.Error("existing XTls should be preserved")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("synthesizes pass from encrypted inbound hop", func(t *testing.T) {
|
|
||||||
results := &model.AuthenticationResults{}
|
|
||||||
chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{
|
|
||||||
Version: utils.PtrTo("TLSv1.3"),
|
|
||||||
Cipher: utils.PtrTo("TLS_AES_256_GCM_SHA384"),
|
|
||||||
Bits: utils.PtrTo(256),
|
|
||||||
}}}
|
|
||||||
analyzer.ReconcileXTLS(results, chain)
|
|
||||||
if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass {
|
|
||||||
t.Fatalf("expected synthesized pass, got %+v", results.XTls)
|
|
||||||
}
|
|
||||||
if results.XTls.Details == nil || !strings.Contains(*results.XTls.Details, "TLSv1.3") {
|
|
||||||
t.Errorf("details should mention TLS version, got %v", results.XTls.Details)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("synthesizes pass from ESMTPS protocol without TLS parenthetical", func(t *testing.T) {
|
|
||||||
// smtpd_tls_received_header disabled: no TLS details, but ESMTPS proves encryption.
|
|
||||||
results := &model.AuthenticationResults{}
|
|
||||||
chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTPS")}}
|
|
||||||
analyzer.ReconcileXTLS(results, chain)
|
|
||||||
if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass {
|
|
||||||
t.Fatalf("expected synthesized pass, got %+v", results.XTls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("synthesizes none from plaintext ESMTP protocol", func(t *testing.T) {
|
|
||||||
results := &model.AuthenticationResults{}
|
|
||||||
chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTP")}}
|
|
||||||
analyzer.ReconcileXTLS(results, chain)
|
|
||||||
if results.XTls == nil || results.XTls.Result != model.AuthResultResultNone {
|
|
||||||
t.Fatalf("expected synthesized none, got %+v", results.XTls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("leaves nil when neither TLS info nor protocol is known", func(t *testing.T) {
|
|
||||||
results := &model.AuthenticationResults{}
|
|
||||||
chain := &[]model.ReceivedHop{{}}
|
|
||||||
analyzer.ReconcileXTLS(results, chain)
|
|
||||||
if results.XTls != nil {
|
|
||||||
t.Errorf("expected nil XTls when undetermined, got %+v", results.XTls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("leaves nil with empty chain", func(t *testing.T) {
|
|
||||||
results := &model.AuthenticationResults{}
|
|
||||||
analyzer.ReconcileXTLS(results, &[]model.ReceivedHop{})
|
|
||||||
if results.XTls != nil {
|
|
||||||
t.Errorf("expected nil XTls, got %+v", results.XTls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProtocolIndicatesTLS(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
with string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"ESMTPS", true},
|
|
||||||
{"ESMTPSA", true},
|
|
||||||
{"SMTPS", true},
|
|
||||||
{"LMTPS", true},
|
|
||||||
{"LMTPSA", true},
|
|
||||||
{"SMTP", false},
|
|
||||||
{"ESMTP", false},
|
|
||||||
{"ESMTPA", false},
|
|
||||||
{"LMTP", false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.with, func(t *testing.T) {
|
|
||||||
if got := protocolIndicatesTLS(utils.PtrTo(tt.with)); got != tt.want {
|
|
||||||
t.Errorf("protocolIndicatesTLS(%q) = %v, want %v", tt.with, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if protocolIndicatesTLS(nil) {
|
|
||||||
t.Error("protocolIndicatesTLS(nil) should be false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -501,11 +500,6 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace email addresses with just their domain part to avoid false positives
|
|
||||||
// e.g. "john.doe@example.com" → "example.com" so local-part dots don't look like domains
|
|
||||||
emailAddrRegex := regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@([a-z0-9.\-]+\.[a-z]{2,})`)
|
|
||||||
linkText = emailAddrRegex.ReplaceAllString(linkText, "$1")
|
|
||||||
|
|
||||||
// Common generic link texts that shouldn't trigger warnings
|
// Common generic link texts that shouldn't trigger warnings
|
||||||
genericTexts := []string{
|
genericTexts := []string{
|
||||||
"click here", "read more", "learn more", "download", "subscribe",
|
"click here", "read more", "learn more", "download", "subscribe",
|
||||||
|
|
@ -734,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)
|
||||||
|
|
@ -756,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"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -779,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"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -835,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)
|
||||||
|
|
@ -869,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 != "" {
|
||||||
|
|
@ -881,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)
|
||||||
}
|
}
|
||||||
|
|
@ -890,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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
@ -88,16 +88,6 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
|
||||||
if len(forwardRecords) > 0 {
|
if len(forwardRecords) > 0 {
|
||||||
results.PtrForwardRecords = &forwardRecords
|
results.PtrForwardRecords = &forwardRecords
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the announced HELO name and whether it matches the PTR record
|
|
||||||
if firstHop.From != nil && *firstHop.From != "" {
|
|
||||||
helo := *firstHop.From
|
|
||||||
results.HeloHostname = &helo
|
|
||||||
if len(ptrRecords) > 0 {
|
|
||||||
match := checkHeloPtrMatch(helo, ptrRecords)
|
|
||||||
results.HeloPtrMatch = &match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,29 +100,25 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
|
||||||
results.RpMxRecords = d.checkMXRecords(*results.RpDomain)
|
results.RpMxRecords = d.checkMXRecords(*results.RpDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the sender domains can actually receive replies/bounces (MX, with
|
|
||||||
// A/AAAA fallback), mirroring the ReturnOK milter check.
|
|
||||||
results.ReturnOk = &model.ReturnOK{
|
|
||||||
From: d.checkReturnOKDomain(fromDomain, orgDomainOrEmpty(headersResults.DomainAlignment.FromOrgDomain)),
|
|
||||||
}
|
|
||||||
if results.RpDomain != nil && *results.RpDomain != "" {
|
|
||||||
results.ReturnOk.ReturnPath = d.checkReturnOKDomain(*results.RpDomain, orgDomainOrEmpty(headersResults.DomainAlignment.ReturnPathOrgDomain))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check SPF records (for Return-Path domain - this is the envelope sender)
|
// Check SPF records (for Return-Path domain - this is the envelope sender)
|
||||||
// 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)
|
if authResults != nil && authResults.Dkim != nil {
|
||||||
|
for _, dkim := range *authResults.Dkim {
|
||||||
|
if dkim.Domain != nil && dkim.Selector != nil {
|
||||||
|
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
|
||||||
if dkimRecord != nil {
|
if dkimRecord != nil {
|
||||||
if results.DkimRecords == nil {
|
if results.DkimRecords == nil {
|
||||||
results.DkimRecords = new([]model.DKIMRecord)
|
results.DkimRecords = new([]api.DKIMRecord)
|
||||||
}
|
}
|
||||||
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check DMARC record (for From domain - DMARC protects the visible sender)
|
// Check DMARC record (for From domain - DMARC protects the visible sender)
|
||||||
// DMARC validates alignment between SPF/DKIM and the From domain
|
// DMARC validates alignment between SPF/DKIM and the From domain
|
||||||
|
|
@ -146,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,11 +143,6 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
|
||||||
// Check SPF records
|
// Check SPF records
|
||||||
results.SpfRecords = d.checkSPFRecords(domain)
|
results.SpfRecords = d.checkSPFRecords(domain)
|
||||||
|
|
||||||
// Verify the domain can receive replies/bounces (MX, with A/AAAA fallback)
|
|
||||||
results.ReturnOk = &model.ReturnOK{
|
|
||||||
From: d.checkReturnOKDomain(domain, ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check DMARC record
|
// Check DMARC record
|
||||||
results.DmarcRecord = d.checkDMARCRecord(domain)
|
results.DmarcRecord = d.checkDMARCRecord(domain)
|
||||||
|
|
||||||
|
|
@ -174,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, ""
|
||||||
}
|
}
|
||||||
|
|
@ -193,9 +174,6 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int,
|
||||||
// DMARC Record: 40 points
|
// DMARC Record: 40 points
|
||||||
score += 40 * d.calculateDMARCScore(results) / 100
|
score += 40 * d.calculateDMARCScore(results) / 100
|
||||||
|
|
||||||
// Penalty when a sender domain cannot receive replies/bounces at all
|
|
||||||
score += calculateReturnOKPenalty(results)
|
|
||||||
|
|
||||||
// BIMI Record: only bonus
|
// BIMI Record: only bonus
|
||||||
if results.BimiRecord != nil && results.BimiRecord.Valid {
|
if results.BimiRecord != nil && results.BimiRecord.Valid {
|
||||||
if score >= 100 {
|
if score >= 100 {
|
||||||
|
|
@ -219,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, ""
|
||||||
}
|
}
|
||||||
|
|
@ -241,9 +219,6 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP stri
|
||||||
// DMARC Record: 20 points
|
// DMARC Record: 20 points
|
||||||
score += 20 * d.calculateDMARCScore(results) / 100
|
score += 20 * d.calculateDMARCScore(results) / 100
|
||||||
|
|
||||||
// Penalty when a sender domain cannot receive replies/bounces at all
|
|
||||||
score += calculateReturnOKPenalty(results)
|
|
||||||
|
|
||||||
// BIMI Record
|
// BIMI Record
|
||||||
// BIMI is optional but indicates advanced email branding
|
// BIMI is optional but indicates advanced email branding
|
||||||
if results.BimiRecord != nil && results.BimiRecord.Valid {
|
if results.BimiRecord != nil && results.BimiRecord.Valid {
|
||||||
|
|
|
||||||
|
|
@ -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: %s", formatDNSError(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,
|
||||||
|
|
|
||||||
|
|
@ -23,178 +23,70 @@ package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/x509"
|
|
||||||
"encoding/base64"
|
|
||||||
"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, selector and signing algorithm 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
|
// DKIM records are at: selector._domainkey.domain
|
||||||
Selector string
|
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
|
||||||
Algorithm string // from a= tag (e.g. rsa-sha256, ed25519-sha256)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDKIMSignatures extracts domain, selector and algorithm from DKIM-Signature header values.
|
|
||||||
func parseDKIMSignatures(signatures []string) []DKIMHeader {
|
|
||||||
var results []DKIMHeader
|
|
||||||
for _, sig := range signatures {
|
|
||||||
var domain, selector, algorithm 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
|
|
||||||
case "a":
|
|
||||||
algorithm = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if domain != "" && selector != "" {
|
|
||||||
results = append(results, DKIMHeader{Domain: domain, Selector: selector, Algorithm: algorithm})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDKIMTags splits a DKIM DNS record into a tag→value map.
|
|
||||||
func parseDKIMTags(record string) map[string]string {
|
|
||||||
tags := make(map[string]string)
|
|
||||||
for _, part := range strings.Split(record, ";") {
|
|
||||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
|
||||||
if len(kv) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tags[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
|
|
||||||
}
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseKeySize derives the public key bit length from a base64-encoded DER public key.
|
|
||||||
// For RSA keys it parses the PKIX structure; for Ed25519 it always returns 256.
|
|
||||||
func parseKeySize(keyType, p string) *int {
|
|
||||||
switch strings.ToLower(keyType) {
|
|
||||||
case "ed25519":
|
|
||||||
return utils.PtrTo(256)
|
|
||||||
case "rsa", "":
|
|
||||||
der, err := base64.StdEncoding.DecodeString(p)
|
|
||||||
if err != nil {
|
|
||||||
// Try without padding
|
|
||||||
der, err = base64.RawStdEncoding.DecodeString(p)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub, err := x509.ParsePKIXPublicKey(der)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if rsaPub, ok := pub.(interface{ Size() int }); ok {
|
|
||||||
bits := rsaPub.Size() * 8
|
|
||||||
return &bits
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkDKIMRecord looks up and validates DKIM record for a domain and selector.
|
|
||||||
func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *model.DKIMRecord {
|
|
||||||
dkimDomain := fmt.Sprintf("%s._domainkey.%s", h.Selector, h.Domain)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
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: h.Selector,
|
Selector: selector,
|
||||||
Domain: h.Domain,
|
Domain: domain,
|
||||||
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %s", formatDNSError(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: h.Selector,
|
Selector: selector,
|
||||||
Domain: h.Domain,
|
Domain: domain,
|
||||||
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("No DKIM record found"),
|
Error: api.PtrTo("No DKIM record found"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Concatenate all TXT record parts (DKIM can be split)
|
// Concatenate all TXT record parts (DKIM can be split)
|
||||||
dkimRecord := strings.Join(txtRecords, "")
|
dkimRecord := strings.Join(txtRecords, "")
|
||||||
|
|
||||||
|
// 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: h.Selector,
|
Selector: selector,
|
||||||
Domain: h.Domain,
|
Domain: domain,
|
||||||
Record: utils.PtrTo(dkimRecord),
|
Record: api.PtrTo(dkimRecord),
|
||||||
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("DKIM record appears malformed"),
|
Error: api.PtrTo("DKIM record appears malformed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tags := parseDKIMTags(dkimRecord)
|
return &api.DKIMRecord{
|
||||||
|
Selector: selector,
|
||||||
keyType := tags["k"]
|
Domain: domain,
|
||||||
if keyType == "" {
|
|
||||||
keyType = "rsa" // RFC 6376 default
|
|
||||||
}
|
|
||||||
|
|
||||||
var hashAlgorithms []string
|
|
||||||
if h, ok := tags["h"]; ok && h != "" {
|
|
||||||
for _, alg := range strings.Split(h, ":") {
|
|
||||||
if a := strings.TrimSpace(alg); a != "" {
|
|
||||||
hashAlgorithms = append(hashAlgorithms, a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hashAlgorithms == nil {
|
|
||||||
hashAlgorithms = []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.DKIMRecord{
|
|
||||||
Selector: h.Selector,
|
|
||||||
Domain: h.Domain,
|
|
||||||
Record: &dkimRecord,
|
Record: &dkimRecord,
|
||||||
KeyType: utils.PtrTo(keyType),
|
|
||||||
HashAlgorithms: &hashAlgorithms,
|
|
||||||
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
|
||||||
KeySize: parseKeySize(keyType, tags["p"]),
|
|
||||||
Valid: true,
|
Valid: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func signingAlgorithmPtr(a string) *string {
|
// validateDKIM performs basic DKIM record validation
|
||||||
if a == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateDKIM performs basic DKIM record validation.
|
|
||||||
func (d *DNSAnalyzer) validateDKIM(record string) bool {
|
func (d *DNSAnalyzer) validateDKIM(record string) bool {
|
||||||
|
// Should contain p= tag (public key)
|
||||||
if !strings.Contains(record, "p=") {
|
if !strings.Contains(record, "p=") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// If v= is present, it must be DKIM1
|
// Often contains v=DKIM1 but not required
|
||||||
|
// If v= is present, it should be DKIM1
|
||||||
if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
|
if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -202,58 +94,22 @@ 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) {
|
||||||
if results.DkimRecords == nil || len(*results.DkimRecords) == 0 {
|
// DKIM provides strong email authentication
|
||||||
return 0
|
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
|
||||||
}
|
hasValidDKIM := false
|
||||||
|
|
||||||
hasValid := false
|
|
||||||
for _, dkim := range *results.DkimRecords {
|
for _, dkim := range *results.DkimRecords {
|
||||||
if dkim.Valid {
|
if dkim.Valid {
|
||||||
hasValid = true
|
hasValidDKIM = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if hasValidDKIM {
|
||||||
if !hasValid {
|
score += 100
|
||||||
return 25
|
} else {
|
||||||
|
// Partial credit if DKIM record exists but has issues
|
||||||
|
score += 25
|
||||||
}
|
}
|
||||||
|
|
||||||
score = 100
|
|
||||||
|
|
||||||
// Apply security penalties on the best valid record
|
|
||||||
for _, dkim := range *results.DkimRecords {
|
|
||||||
if !dkim.Valid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// SHA-1 signing is deprecated (RFC 8301)
|
|
||||||
if dkim.SigningAlgorithm != nil && strings.HasSuffix(*dkim.SigningAlgorithm, "-sha1") {
|
|
||||||
if score > 60 {
|
|
||||||
score = 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key size penalties apply only to RSA
|
|
||||||
keyType := ""
|
|
||||||
if dkim.KeyType != nil {
|
|
||||||
keyType = strings.ToLower(*dkim.KeyType)
|
|
||||||
}
|
|
||||||
if keyType == "rsa" || keyType == "" {
|
|
||||||
if dkim.KeySize != nil {
|
|
||||||
switch {
|
|
||||||
case *dkim.KeySize < 1024:
|
|
||||||
if score > 25 {
|
|
||||||
score = 25
|
|
||||||
}
|
|
||||||
case *dkim.KeySize < 2048:
|
|
||||||
if score > 75 {
|
|
||||||
score = 75
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ed25519 keys (256-bit curve, ~3000-bit RSA equivalent) need no penalty.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -22,231 +22,10 @@
|
||||||
package analyzer
|
package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/base64"
|
|
||||||
"testing"
|
"testing"
|
||||||
"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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "ed25519-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"},
|
|
||||||
{Domain: "sendib.com", Selector: "mail", Algorithm: "rsa-sha256"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "ed25519-sha256"},
|
|
||||||
{Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"},
|
|
||||||
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt", Algorithm: "rsa-sha256"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha1"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", Algorithm: "rsa-sha256"},
|
|
||||||
{Domain: "also-good.com", Selector: "sel2", Algorithm: "rsa-sha256"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if result[i].Algorithm != tt.expected[i].Algorithm {
|
|
||||||
t.Errorf("result[%d].Algorithm = %q, want %q", i, result[i].Algorithm, tt.expected[i].Algorithm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateDKIM(t *testing.T) {
|
func TestValidateDKIM(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -291,119 +70,3 @@ func TestValidateDKIM(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDKIMTags(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
record string
|
|
||||||
wantTags map[string]string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "standard RSA record",
|
|
||||||
record: "v=DKIM1; k=rsa; p=MIIBI; h=sha256",
|
|
||||||
wantTags: map[string]string{"v": "DKIM1", "k": "rsa", "p": "MIIBI", "h": "sha256"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ed25519 record",
|
|
||||||
record: "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS",
|
|
||||||
wantTags: map[string]string{"v": "DKIM1", "k": "ed25519", "p": "11qYAYKxCrfVS"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing k= defaults",
|
|
||||||
record: "v=DKIM1; p=MIIBI",
|
|
||||||
wantTags: map[string]string{"v": "DKIM1", "p": "MIIBI"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty record",
|
|
||||||
record: "",
|
|
||||||
wantTags: map[string]string{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := parseDKIMTags(tt.record)
|
|
||||||
for key, want := range tt.wantTags {
|
|
||||||
if got[key] != want {
|
|
||||||
t.Errorf("tag %q = %q, want %q", key, got[key], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseKeySize(t *testing.T) {
|
|
||||||
// Generate a real RSA key for testing
|
|
||||||
rsaKey1024, _ := rsa.GenerateKey(rand.Reader, 1024)
|
|
||||||
rsaKey2048, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
||||||
|
|
||||||
der1024, _ := x509.MarshalPKIXPublicKey(&rsaKey1024.PublicKey)
|
|
||||||
der2048, _ := x509.MarshalPKIXPublicKey(&rsaKey2048.PublicKey)
|
|
||||||
|
|
||||||
p1024 := base64.StdEncoding.EncodeToString(der1024)
|
|
||||||
p2048 := base64.StdEncoding.EncodeToString(der2048)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
keyType string
|
|
||||||
p string
|
|
||||||
want *int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "RSA 1024",
|
|
||||||
keyType: "rsa",
|
|
||||||
p: p1024,
|
|
||||||
want: intPtr(1024),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "RSA 2048",
|
|
||||||
keyType: "rsa",
|
|
||||||
p: p2048,
|
|
||||||
want: intPtr(2048),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Ed25519 always 256",
|
|
||||||
keyType: "ed25519",
|
|
||||||
p: "11qYAYKxCrfVS",
|
|
||||||
want: intPtr(256),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Unknown key type",
|
|
||||||
keyType: "unknown",
|
|
||||||
p: "somedata",
|
|
||||||
want: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid RSA base64",
|
|
||||||
keyType: "rsa",
|
|
||||||
p: "!!!not-base64!!!",
|
|
||||||
want: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty k= defaults to RSA",
|
|
||||||
keyType: "",
|
|
||||||
p: p2048,
|
|
||||||
want: intPtr(2048),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := parseKeySize(tt.keyType, tt.p)
|
|
||||||
if tt.want == nil {
|
|
||||||
if got != nil {
|
|
||||||
t.Errorf("parseKeySize(%q, ...) = %d, want nil", tt.keyType, *got)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got == nil {
|
|
||||||
t.Fatalf("parseKeySize(%q, ...) = nil, want %d", tt.keyType, *tt.want)
|
|
||||||
}
|
|
||||||
if *got != *tt.want {
|
|
||||||
t.Errorf("parseKeySize(%q, ...) = %d, want %d", tt.keyType, *got, *tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func intPtr(v int) *int { return &v }
|
|
||||||
|
|
|
||||||
|
|
@ -24,291 +24,233 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var dmarcPolicyStrength = map[string]int{"none": 0, "quarantine": 1, "reject": 2}
|
// checkapi.DMARCRecord looks up and validates DMARC record for a domain
|
||||||
|
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord {
|
||||||
|
// DMARC records are at: _dmarc.domain
|
||||||
|
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
|
||||||
|
|
||||||
// lookupDMARCAt queries _dmarc.<domain> and returns the raw DMARC1 TXT record.
|
|
||||||
// notFound=true means no record exists (NXDOMAIN or empty); false means a real DNS error occurred.
|
|
||||||
func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool, err error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
txtRecords, lookupErr := d.resolver.LookupTXT(ctx, fmt.Sprintf("_dmarc.%s", domain))
|
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
|
||||||
if lookupErr != nil {
|
if err != nil {
|
||||||
if dnsErr, ok := lookupErr.(*net.DNSError); ok && dnsErr.IsNotFound {
|
return &api.DMARCRecord{
|
||||||
return "", true, nil
|
Valid: false,
|
||||||
|
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||||
}
|
}
|
||||||
return "", false, lookupErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find DMARC record (starts with "v=DMARC1")
|
||||||
|
var dmarcRecord string
|
||||||
for _, txt := range txtRecords {
|
for _, txt := range txtRecords {
|
||||||
if strings.HasPrefix(txt, "v=DMARC1") {
|
if strings.HasPrefix(txt, "v=DMARC1") {
|
||||||
return txt, false, nil
|
dmarcRecord = txt
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDMARCRecord parses a raw DMARC TXT record into a DMARCRecord model.
|
|
||||||
func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord {
|
|
||||||
tags := parseDKIMTags(rawRecord)
|
|
||||||
|
|
||||||
// Policy
|
|
||||||
policy := "unknown"
|
|
||||||
switch tags["p"] {
|
|
||||||
case "none", "quarantine", "reject":
|
|
||||||
policy = tags["p"]
|
|
||||||
}
|
|
||||||
|
|
||||||
// SPF alignment (default: relaxed)
|
|
||||||
spfAlignment := utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
|
|
||||||
if tags["aspf"] == "s" {
|
|
||||||
spfAlignment = utils.PtrTo(model.DMARCRecordSpfAlignmentStrict)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DKIM alignment (default: relaxed)
|
|
||||||
dkimAlignment := utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
|
|
||||||
if tags["adkim"] == "s" {
|
|
||||||
dkimAlignment = utils.PtrTo(model.DMARCRecordDkimAlignmentStrict)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subdomain policy
|
|
||||||
var subdomainPolicy *model.DMARCRecordSubdomainPolicy
|
|
||||||
switch tags["sp"] {
|
|
||||||
case "none", "quarantine", "reject":
|
|
||||||
subdomainPolicy = utils.PtrTo(model.DMARCRecordSubdomainPolicy(tags["sp"]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-existent subdomain policy (DMARCbis np=)
|
|
||||||
var nonexistentSubdomainPolicy *model.DMARCRecordNonexistentSubdomainPolicy
|
|
||||||
switch tags["np"] {
|
|
||||||
case "none", "quarantine", "reject":
|
|
||||||
nonexistentSubdomainPolicy = utils.PtrTo(model.DMARCRecordNonexistentSubdomainPolicy(tags["np"]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Percentage (pct=, deprecated in DMARCbis)
|
|
||||||
var percentage *int
|
|
||||||
if pctStr, ok := tags["pct"]; ok {
|
|
||||||
if pct, err := strconv.Atoi(pctStr); err == nil && pct >= 0 && pct <= 100 {
|
|
||||||
percentage = &pct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test mode (DMARCbis t=)
|
|
||||||
var testMode *bool
|
|
||||||
if t, ok := tags["t"]; ok {
|
|
||||||
v := t == "y"
|
|
||||||
testMode = &v
|
|
||||||
}
|
|
||||||
|
|
||||||
// PSD (DMARCbis psd=)
|
|
||||||
var psd *model.DMARCRecordPsd
|
|
||||||
switch tags["psd"] {
|
|
||||||
case "y", "n", "u":
|
|
||||||
psd = utils.PtrTo(model.DMARCRecordPsd(tags["psd"]))
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := &model.DMARCRecord{
|
|
||||||
Domain: &foundDomain,
|
|
||||||
Record: &rawRecord,
|
|
||||||
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
|
||||||
SubdomainPolicy: subdomainPolicy,
|
|
||||||
NonexistentSubdomainPolicy: nonexistentSubdomainPolicy,
|
|
||||||
Percentage: percentage,
|
|
||||||
TestMode: testMode,
|
|
||||||
Psd: psd,
|
|
||||||
SpfAlignment: spfAlignment,
|
|
||||||
DkimAlignment: dkimAlignment,
|
|
||||||
}
|
|
||||||
if percentage != nil {
|
|
||||||
rec.DeprecatedPct = utils.PtrTo(true)
|
|
||||||
}
|
|
||||||
if _, ok := tags["rf"]; ok {
|
|
||||||
rec.DeprecatedRf = utils.PtrTo(true)
|
|
||||||
}
|
|
||||||
if _, ok := tags["ri"]; ok {
|
|
||||||
rec.DeprecatedRi = utils.PtrTo(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !d.validateDMARC(rawRecord) {
|
|
||||||
rec.Valid = false
|
|
||||||
rec.Error = utils.PtrTo("DMARC record appears malformed")
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
rec.Valid = true
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
// walkDNSForDMARC implements the DMARCbis DNS Tree Walk algorithm (Section 4.10).
|
|
||||||
// It queries _dmarc.<domain> and walks up the label hierarchy until a valid DMARC
|
|
||||||
// record is found or all labels are exhausted. Maximum 8 DNS queries per message.
|
|
||||||
// For domains with ≥8 labels, after the initial miss the walk jumps to the 7-label
|
|
||||||
// suffix before resuming normally (to stay within the 8-query budget).
|
|
||||||
// Single-label (TLD) records are only accepted when they carry psd=y.
|
|
||||||
func (d *DNSAnalyzer) walkDNSForDMARC(domain string) (record, foundDomain string, err error) {
|
|
||||||
labels := strings.Split(strings.ToLower(strings.TrimSuffix(domain, ".")), ".")
|
|
||||||
n := len(labels)
|
|
||||||
|
|
||||||
for i, queries := 0, 0; i < n && queries < 8; i, queries = i+1, queries+1 {
|
|
||||||
current := strings.Join(labels[i:], ".")
|
|
||||||
|
|
||||||
raw, notFound, lookupErr := d.lookupDMARCAt(current)
|
|
||||||
if lookupErr != nil {
|
|
||||||
return "", "", lookupErr
|
|
||||||
}
|
|
||||||
if !notFound {
|
|
||||||
// Single-label (TLD) records are only used when the record explicitly opts in.
|
|
||||||
if !strings.Contains(current, ".") {
|
|
||||||
if d.extractDMARCPSDValue(raw) != "y" {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return raw, current, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DMARCbis §4.10: after missing on a ≥8-label domain, shortcut to the
|
if dmarcRecord == "" {
|
||||||
// 7-label suffix for the next query rather than stepping one label at a time.
|
return &api.DMARCRecord{
|
||||||
if i == 0 && n >= 8 {
|
|
||||||
i = n - 8 // the outer i++ will land at n-7 (7 labels from the right)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkDMARCRecord looks up and validates the DMARC record for a domain using
|
|
||||||
// the DMARCbis DNS Tree Walk algorithm (Section 4.10), which supersedes the
|
|
||||||
// RFC 7489 PSL-based organizational domain lookup and the RFC 9091 PSD DMARC
|
|
||||||
// experimental fallback.
|
|
||||||
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
|
||||||
raw, foundDomain, err := d.walkDNSForDMARC(domain)
|
|
||||||
if err != nil {
|
|
||||||
return &model.DMARCRecord{
|
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %s", formatDNSError(err))),
|
Error: api.PtrTo("No DMARC record found"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if foundDomain == "" {
|
|
||||||
return &model.DMARCRecord{
|
// Extract policy
|
||||||
|
policy := d.extractDMARCPolicy(dmarcRecord)
|
||||||
|
|
||||||
|
// Extract subdomain policy
|
||||||
|
subdomainPolicy := d.extractDMARCSubdomainPolicy(dmarcRecord)
|
||||||
|
|
||||||
|
// Extract percentage
|
||||||
|
percentage := d.extractDMARCPercentage(dmarcRecord)
|
||||||
|
|
||||||
|
// Extract alignment modes
|
||||||
|
spfAlignment := d.extractDMARCSPFAlignment(dmarcRecord)
|
||||||
|
dkimAlignment := d.extractDMARCDKIMAlignment(dmarcRecord)
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if !d.validateDMARC(dmarcRecord) {
|
||||||
|
return &api.DMARCRecord{
|
||||||
|
Record: &dmarcRecord,
|
||||||
|
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
|
||||||
|
SubdomainPolicy: subdomainPolicy,
|
||||||
|
Percentage: percentage,
|
||||||
|
SpfAlignment: spfAlignment,
|
||||||
|
DkimAlignment: dkimAlignment,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("No DMARC record found"),
|
Error: api.PtrTo("DMARC record appears malformed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return d.parseDMARCRecord(foundDomain, raw)
|
|
||||||
|
return &api.DMARCRecord{
|
||||||
|
Record: &dmarcRecord,
|
||||||
|
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
|
||||||
|
SubdomainPolicy: subdomainPolicy,
|
||||||
|
Percentage: percentage,
|
||||||
|
SpfAlignment: spfAlignment,
|
||||||
|
DkimAlignment: dkimAlignment,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDMARCPSDValue returns the raw psd= value ("y", "n", "u") or "" if absent.
|
// extractDMARCPolicy extracts the policy from a DMARC record
|
||||||
// Used during DNS Tree Walk before full record parsing.
|
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
|
||||||
func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string {
|
// Look for p=none, p=quarantine, or p=reject
|
||||||
v := parseDKIMTags(record)["psd"]
|
re := regexp.MustCompile(`p=(none|quarantine|reject)`)
|
||||||
switch v {
|
matches := re.FindStringSubmatch(record)
|
||||||
case "y", "n", "u":
|
if len(matches) > 1 {
|
||||||
return v
|
return matches[1]
|
||||||
}
|
}
|
||||||
return ""
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateDMARC performs basic DMARC record validation.
|
// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record
|
||||||
// Per DMARCbis, p= is now RECOMMENDED (not required): a record with a valid
|
// Returns "relaxed" (default) or "strict"
|
||||||
// rua= but no p= is treated as p=none and considered valid.
|
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment {
|
||||||
|
// Look for aspf=s (strict) or aspf=r (relaxed)
|
||||||
|
re := regexp.MustCompile(`aspf=(r|s)`)
|
||||||
|
matches := re.FindStringSubmatch(record)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
if matches[1] == "s" {
|
||||||
|
return api.PtrTo(api.DMARCRecordSpfAlignmentStrict)
|
||||||
|
}
|
||||||
|
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
|
||||||
|
}
|
||||||
|
// Default is relaxed if not specified
|
||||||
|
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record
|
||||||
|
// Returns "relaxed" (default) or "strict"
|
||||||
|
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment {
|
||||||
|
// Look for adkim=s (strict) or adkim=r (relaxed)
|
||||||
|
re := regexp.MustCompile(`adkim=(r|s)`)
|
||||||
|
matches := re.FindStringSubmatch(record)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
if matches[1] == "s" {
|
||||||
|
return api.PtrTo(api.DMARCRecordDkimAlignmentStrict)
|
||||||
|
}
|
||||||
|
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
|
||||||
|
}
|
||||||
|
// Default is relaxed if not specified
|
||||||
|
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record
|
||||||
|
// Returns the sp tag value or nil if not specified (defaults to main policy)
|
||||||
|
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy {
|
||||||
|
// Look for sp=none, sp=quarantine, or sp=reject
|
||||||
|
re := regexp.MustCompile(`sp=(none|quarantine|reject)`)
|
||||||
|
matches := re.FindStringSubmatch(record)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1]))
|
||||||
|
}
|
||||||
|
// If sp is not specified, it defaults to the main policy (p tag)
|
||||||
|
// Return nil to indicate it's using the default
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDMARCPercentage extracts the percentage from a DMARC record
|
||||||
|
// Returns the pct tag value or nil if not specified (defaults to 100)
|
||||||
|
func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int {
|
||||||
|
// Look for pct=<number>
|
||||||
|
re := regexp.MustCompile(`pct=(\d+)`)
|
||||||
|
matches := re.FindStringSubmatch(record)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
// Convert string to int
|
||||||
|
var pct int
|
||||||
|
fmt.Sscanf(matches[1], "%d", &pct)
|
||||||
|
// Validate range (0-100)
|
||||||
|
if pct >= 0 && pct <= 100 {
|
||||||
|
return &pct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default is 100 if not specified
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDMARC performs basic DMARC record validation
|
||||||
func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
||||||
|
// Must start with v=DMARC1
|
||||||
if !strings.HasPrefix(record, "v=DMARC1") {
|
if !strings.HasPrefix(record, "v=DMARC1") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// p= absent is allowed in DMARCbis when rua= is present (treated as p=none).
|
// Must have a policy tag
|
||||||
if !strings.Contains(record, "p=") {
|
if !strings.Contains(record, "p=") {
|
||||||
return strings.Contains(record, "rua=")
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) {
|
func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) {
|
||||||
if results.DmarcRecord == nil {
|
// DMARC ties SPF and DKIM together and provides policy
|
||||||
return
|
if results.DmarcRecord != nil {
|
||||||
}
|
if results.DmarcRecord.Valid {
|
||||||
|
|
||||||
if !results.DmarcRecord.Valid {
|
|
||||||
if results.DmarcRecord.Record != nil {
|
|
||||||
// Partial credit if a DMARC record exists but has issues
|
|
||||||
score += 20
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
score += 50
|
score += 50
|
||||||
|
// Bonus points for stricter policies
|
||||||
// Determine effective policy: DMARCbis t=y downgrades policy one level.
|
|
||||||
effectivePolicy := "none"
|
|
||||||
if results.DmarcRecord.Policy != nil {
|
if results.DmarcRecord.Policy != nil {
|
||||||
effectivePolicy = string(*results.DmarcRecord.Policy)
|
switch *results.DmarcRecord.Policy {
|
||||||
}
|
|
||||||
testMode := results.DmarcRecord.TestMode != nil && *results.DmarcRecord.TestMode
|
|
||||||
if testMode {
|
|
||||||
switch effectivePolicy {
|
|
||||||
case "reject":
|
|
||||||
effectivePolicy = "quarantine"
|
|
||||||
case "quarantine":
|
|
||||||
effectivePolicy = "none"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bonus/penalty for policy strength
|
|
||||||
switch effectivePolicy {
|
|
||||||
case "reject":
|
case "reject":
|
||||||
|
// Strictest policy - full points already awarded
|
||||||
score += 25
|
score += 25
|
||||||
|
case "quarantine":
|
||||||
|
// Good policy - no deduction
|
||||||
case "none":
|
case "none":
|
||||||
|
// Weakest policy - deduct 5 points
|
||||||
score -= 25
|
score -= 25
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Bonus points for strict alignment modes
|
// 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): +15 for equal-or-stricter, -15 for weaker
|
// +3 for stricter or equal subdomain policy, -3 for weaker
|
||||||
if results.DmarcRecord.SubdomainPolicy != nil {
|
if results.DmarcRecord.SubdomainPolicy != nil {
|
||||||
|
mainPolicy := string(*results.DmarcRecord.Policy)
|
||||||
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
|
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
|
||||||
if dmarcPolicyStrength[subPolicy] >= dmarcPolicyStrength[effectivePolicy] {
|
|
||||||
|
// Policy strength: none < quarantine < reject
|
||||||
|
policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2}
|
||||||
|
|
||||||
|
mainStrength := policyStrength[mainPolicy]
|
||||||
|
subStrength := policyStrength[subPolicy]
|
||||||
|
|
||||||
|
if subStrength >= mainStrength {
|
||||||
|
// Subdomain policy is equal or stricter
|
||||||
score += 15
|
score += 15
|
||||||
} else {
|
} else {
|
||||||
|
// Subdomain policy is weaker
|
||||||
score -= 15
|
score -= 15
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
score += 15 // inherits main policy — good default
|
// No sp tag means subdomains inherit main policy (good default)
|
||||||
}
|
|
||||||
|
|
||||||
// Non-existent subdomain policy scoring (np tag, DMARCbis): +15 for equal-or-stricter, -15 for weaker
|
|
||||||
effectiveSubPolicy := effectivePolicy
|
|
||||||
if results.DmarcRecord.SubdomainPolicy != nil {
|
|
||||||
effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy)
|
|
||||||
}
|
|
||||||
if results.DmarcRecord.NonexistentSubdomainPolicy == nil {
|
|
||||||
score += 15 // inherits subdomain/main policy — good default
|
|
||||||
} else if dmarcPolicyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] >= dmarcPolicyStrength[effectiveSubPolicy] {
|
|
||||||
score += 15
|
score += 15
|
||||||
} else {
|
|
||||||
score -= 15
|
|
||||||
}
|
}
|
||||||
|
// Percentage scoring (pct tag)
|
||||||
// pct= scaling (deprecated in DMARCbis, kept for backward compatibility).
|
// Apply the percentage on the current score
|
||||||
// pct=0 is an anti-pattern: score it as zero enforcement.
|
|
||||||
if results.DmarcRecord.Percentage != nil {
|
if results.DmarcRecord.Percentage != nil {
|
||||||
pct := *results.DmarcRecord.Percentage
|
pct := *results.DmarcRecord.Percentage
|
||||||
|
|
||||||
score = score * pct / 100
|
score = score * pct / 100
|
||||||
}
|
}
|
||||||
|
} else if results.DmarcRecord.Record != nil {
|
||||||
|
// Partial credit if DMARC record exists but has issues
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,206 +22,13 @@
|
||||||
package analyzer
|
package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockDNSResolver maps domain names to TXT records for testing.
|
func TestExtractDMARCPolicy(t *testing.T) {
|
||||||
// An entry with value nil means NXDOMAIN; an error value triggers a DNS error.
|
|
||||||
type mockDNSResolver struct {
|
|
||||||
txt map[string][]string
|
|
||||||
err map[string]error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockDNSResolver) LookupTXT(_ context.Context, name string) ([]string, error) {
|
|
||||||
if err, ok := m.err[name]; ok {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if records, ok := m.txt[name]; ok {
|
|
||||||
return records, nil
|
|
||||||
}
|
|
||||||
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockDNSResolver) LookupMX(_ context.Context, _ string) ([]*net.MX, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockDNSResolver) LookupAddr(_ context.Context, _ string) ([]string, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockDNSResolver) LookupHost(_ context.Context, _ string) ([]string, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMockAnalyzer(txt map[string][]string, errMap map[string]error) *DNSAnalyzer {
|
|
||||||
if errMap == nil {
|
|
||||||
errMap = map[string]error{}
|
|
||||||
}
|
|
||||||
return NewDNSAnalyzerWithResolver(5*time.Second, &mockDNSResolver{txt: txt, err: errMap})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckDMARCRecordFallback(t *testing.T) {
|
|
||||||
const orgRecord = "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
|
|
||||||
const subRecord = "v=DMARC1; p=reject"
|
|
||||||
const psdRecord = "v=DMARC1; p=none; psd=y"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
domain string
|
|
||||||
txt map[string][]string
|
|
||||||
errMap map[string]error
|
|
||||||
wantValid bool
|
|
||||||
wantDomain *string
|
|
||||||
wantErrSubst string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "exact domain has DMARC record — no fallback",
|
|
||||||
domain: "mail.example.com",
|
|
||||||
txt: map[string][]string{
|
|
||||||
"_dmarc.mail.example.com": {subRecord},
|
|
||||||
"_dmarc.example.com": {orgRecord},
|
|
||||||
},
|
|
||||||
wantValid: true,
|
|
||||||
wantDomain: utils.PtrTo("mail.example.com"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact domain NXDOMAIN — tree walk reaches org domain",
|
|
||||||
domain: "mail.example.com",
|
|
||||||
txt: map[string][]string{
|
|
||||||
"_dmarc.example.com": {orgRecord},
|
|
||||||
},
|
|
||||||
wantValid: true,
|
|
||||||
wantDomain: utils.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact domain has no v=DMARC1 TXT — tree walk reaches org domain",
|
|
||||||
domain: "mail.example.com",
|
|
||||||
txt: map[string][]string{
|
|
||||||
"_dmarc.mail.example.com": {"some-other-txt"},
|
|
||||||
"_dmarc.example.com": {orgRecord},
|
|
||||||
},
|
|
||||||
wantValid: true,
|
|
||||||
wantDomain: utils.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "both exact and org NXDOMAIN but PSD (TLD) has psd=y — DMARCbis Tree Walk",
|
|
||||||
domain: "mail.example.com",
|
|
||||||
txt: map[string][]string{
|
|
||||||
"_dmarc.com": {psdRecord},
|
|
||||||
},
|
|
||||||
wantValid: true,
|
|
||||||
wantDomain: utils.PtrTo("com"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "PSD record exists but no psd=y — TLD record ignored by Tree Walk",
|
|
||||||
domain: "mail.example.com",
|
|
||||||
txt: map[string][]string{
|
|
||||||
"_dmarc.com": {"v=DMARC1; p=none"},
|
|
||||||
},
|
|
||||||
wantValid: false,
|
|
||||||
wantErrSubst: "No DMARC record found",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no record at any level",
|
|
||||||
domain: "mail.example.com",
|
|
||||||
txt: map[string][]string{},
|
|
||||||
wantValid: false,
|
|
||||||
wantErrSubst: "No DMARC record found",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "DNS error on exact domain — error returned",
|
|
||||||
domain: "mail.example.com",
|
|
||||||
errMap: map[string]error{
|
|
||||||
"_dmarc.mail.example.com": fmt.Errorf("SERVFAIL"),
|
|
||||||
},
|
|
||||||
wantValid: false,
|
|
||||||
wantErrSubst: "SERVFAIL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "domain already at org level — found immediately",
|
|
||||||
domain: "example.com",
|
|
||||||
txt: map[string][]string{
|
|
||||||
"_dmarc.example.com": {orgRecord},
|
|
||||||
},
|
|
||||||
wantValid: true,
|
|
||||||
wantDomain: utils.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "deep subdomain — tree walk finds record two levels up",
|
|
||||||
domain: "a.b.example.com",
|
|
||||||
txt: map[string][]string{
|
|
||||||
"_dmarc.example.com": {orgRecord},
|
|
||||||
},
|
|
||||||
wantValid: true,
|
|
||||||
wantDomain: utils.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "8-label domain — shortcut to 7-label suffix on miss",
|
|
||||||
domain: "a.b.c.d.e.f.example.com",
|
|
||||||
txt: map[string][]string{
|
|
||||||
"_dmarc.b.c.d.e.f.example.com": {orgRecord},
|
|
||||||
},
|
|
||||||
wantValid: true,
|
|
||||||
wantDomain: utils.PtrTo("b.c.d.e.f.example.com"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "psd=n record stops tree walk at that level",
|
|
||||||
domain: "mail.sub.example.com",
|
|
||||||
txt: map[string][]string{
|
|
||||||
"_dmarc.sub.example.com": {"v=DMARC1; p=reject; psd=n"},
|
|
||||||
},
|
|
||||||
wantValid: true,
|
|
||||||
wantDomain: utils.PtrTo("sub.example.com"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
analyzer := newMockAnalyzer(tt.txt, tt.errMap)
|
|
||||||
result := analyzer.checkDMARCRecord(tt.domain)
|
|
||||||
|
|
||||||
if result.Valid != tt.wantValid {
|
|
||||||
t.Errorf("Valid = %v, want %v", result.Valid, tt.wantValid)
|
|
||||||
}
|
|
||||||
if tt.wantDomain != nil {
|
|
||||||
if result.Domain == nil {
|
|
||||||
t.Fatalf("Domain = nil, want %q", *tt.wantDomain)
|
|
||||||
}
|
|
||||||
if *result.Domain != *tt.wantDomain {
|
|
||||||
t.Errorf("Domain = %q, want %q", *result.Domain, *tt.wantDomain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tt.wantErrSubst != "" {
|
|
||||||
if result.Error == nil {
|
|
||||||
t.Fatalf("Error = nil, want substring %q", tt.wantErrSubst)
|
|
||||||
}
|
|
||||||
if !contains(*result.Error, tt.wantErrSubst) {
|
|
||||||
t.Errorf("Error = %q, want substring %q", *result.Error, tt.wantErrSubst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(s, substr string) bool {
|
|
||||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsStr(s, sub string) bool {
|
|
||||||
for i := 0; i <= len(s)-len(sub); i++ {
|
|
||||||
if s[i:i+len(sub)] == sub {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDMARCRecordPolicy(t *testing.T) {
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
record string
|
record string
|
||||||
|
|
@ -253,135 +60,9 @@ func TestParseDMARCRecordPolicy(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
rec := analyzer.parseDMARCRecord("example.com", tt.record)
|
result := analyzer.extractDMARCPolicy(tt.record)
|
||||||
if rec.Policy == nil {
|
if result != tt.expectedPolicy {
|
||||||
t.Fatalf("parseDMARCRecord(%q).Policy = nil", tt.record)
|
t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy)
|
||||||
}
|
|
||||||
if string(*rec.Policy) != tt.expectedPolicy {
|
|
||||||
t.Errorf("parseDMARCRecord(%q).Policy = %q, want %q", tt.record, string(*rec.Policy), tt.expectedPolicy)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDMARCRecordTestMode(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
record string
|
|
||||||
wantMode *bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "t=y sets test mode",
|
|
||||||
record: "v=DMARC1; p=reject; t=y",
|
|
||||||
wantMode: utils.PtrTo(true),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "t=n explicitly disables test mode",
|
|
||||||
record: "v=DMARC1; p=reject; t=n",
|
|
||||||
wantMode: utils.PtrTo(false),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "absent t tag returns nil",
|
|
||||||
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
|
|
||||||
wantMode: nil,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := analyzer.parseDMARCRecord("example.com", tt.record).TestMode
|
|
||||||
if tt.wantMode == nil {
|
|
||||||
if result != nil {
|
|
||||||
t.Errorf("parseDMARCRecord(%q).TestMode = %v, want nil", tt.record, *result)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if result == nil {
|
|
||||||
t.Fatalf("parseDMARCRecord(%q).TestMode = nil, want %v", tt.record, *tt.wantMode)
|
|
||||||
}
|
|
||||||
if *result != *tt.wantMode {
|
|
||||||
t.Errorf("parseDMARCRecord(%q).TestMode = %v, want %v", tt.record, *result, *tt.wantMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDMARCRecordPSD(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
record string
|
|
||||||
wantPSD *string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "psd=y marks Public Suffix Domain",
|
|
||||||
record: "v=DMARC1; p=none; psd=y",
|
|
||||||
wantPSD: utils.PtrTo("y"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "psd=n marks Org Domain boundary",
|
|
||||||
record: "v=DMARC1; p=reject; psd=n",
|
|
||||||
wantPSD: utils.PtrTo("n"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "psd=u is explicit unknown",
|
|
||||||
record: "v=DMARC1; p=quarantine; psd=u",
|
|
||||||
wantPSD: utils.PtrTo("u"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "absent psd tag returns nil",
|
|
||||||
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
|
|
||||||
wantPSD: nil,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := analyzer.parseDMARCRecord("example.com", tt.record).Psd
|
|
||||||
if tt.wantPSD == nil {
|
|
||||||
if result != nil {
|
|
||||||
t.Errorf("parseDMARCRecord(%q).Psd = %v, want nil", tt.record, *result)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if result == nil {
|
|
||||||
t.Fatalf("parseDMARCRecord(%q).Psd = nil, want %q", tt.record, *tt.wantPSD)
|
|
||||||
}
|
|
||||||
if string(*result) != *tt.wantPSD {
|
|
||||||
t.Errorf("parseDMARCRecord(%q).Psd = %q, want %q", tt.record, string(*result), *tt.wantPSD)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDMARCRecordDeprecatedTags(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
record string
|
|
||||||
wantRf bool
|
|
||||||
wantRi bool
|
|
||||||
}{
|
|
||||||
{name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", wantRf: true, wantRi: false},
|
|
||||||
{name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", wantRf: false, wantRi: true},
|
|
||||||
{name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", wantRf: false, wantRi: false},
|
|
||||||
{name: "ri tag absent", record: "v=DMARC1; p=quarantine", wantRf: false, wantRi: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
rec := analyzer.parseDMARCRecord("example.com", tt.record)
|
|
||||||
gotRf := rec.DeprecatedRf != nil && *rec.DeprecatedRf
|
|
||||||
gotRi := rec.DeprecatedRi != nil && *rec.DeprecatedRi
|
|
||||||
if gotRf != tt.wantRf {
|
|
||||||
t.Errorf("parseDMARCRecord(%q).DeprecatedRf = %v, want %v", tt.record, gotRf, tt.wantRf)
|
|
||||||
}
|
|
||||||
if gotRi != tt.wantRi {
|
|
||||||
t.Errorf("parseDMARCRecord(%q).DeprecatedRi = %v, want %v", tt.record, gotRi, tt.wantRi)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -403,18 +84,13 @@ func TestValidateDMARC(t *testing.T) {
|
||||||
record: "v=DMARC1; p=none",
|
record: "v=DMARC1; p=none",
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "DMARCbis: p= absent but rua= present is valid (treated as p=none)",
|
|
||||||
record: "v=DMARC1; rua=mailto:dmarc@example.com",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Invalid DMARC - no version",
|
name: "Invalid DMARC - no version",
|
||||||
record: "p=quarantine",
|
record: "p=quarantine",
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Invalid DMARC - no policy and no rua",
|
name: "Invalid DMARC - no policy",
|
||||||
record: "v=DMARC1",
|
record: "v=DMARC1",
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
|
|
@ -437,36 +113,41 @@ func TestValidateDMARC(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDMARCRecordAlignment(t *testing.T) {
|
func TestExtractDMARCSPFAlignment(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
record string
|
record string
|
||||||
expectedSPF string
|
expectedAlignment string
|
||||||
expectedDKIM string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "SPF strict, DKIM relaxed",
|
name: "SPF alignment - strict",
|
||||||
|
record: "v=DMARC1; p=quarantine; aspf=s",
|
||||||
|
expectedAlignment: "strict",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SPF alignment - relaxed (explicit)",
|
||||||
|
record: "v=DMARC1; p=quarantine; aspf=r",
|
||||||
|
expectedAlignment: "relaxed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SPF alignment - relaxed (default, not specified)",
|
||||||
|
record: "v=DMARC1; p=quarantine",
|
||||||
|
expectedAlignment: "relaxed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Both alignments specified - check SPF strict",
|
||||||
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
|
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
|
||||||
expectedSPF: "strict",
|
expectedAlignment: "strict",
|
||||||
expectedDKIM: "relaxed",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF relaxed explicit, DKIM strict",
|
name: "Both alignments specified - check SPF relaxed",
|
||||||
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
|
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
|
||||||
expectedSPF: "relaxed",
|
expectedAlignment: "relaxed",
|
||||||
expectedDKIM: "strict",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Defaults when neither specified",
|
name: "Complex record with SPF strict",
|
||||||
record: "v=DMARC1; p=quarantine",
|
|
||||||
expectedSPF: "relaxed",
|
|
||||||
expectedDKIM: "relaxed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Both strict in complex record",
|
|
||||||
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100",
|
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100",
|
||||||
expectedSPF: "strict",
|
expectedAlignment: "strict",
|
||||||
expectedDKIM: "strict",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,53 +155,52 @@ func TestParseDMARCRecordAlignment(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
rec := analyzer.parseDMARCRecord("example.com", tt.record)
|
result := analyzer.extractDMARCSPFAlignment(tt.record)
|
||||||
if rec.SpfAlignment == nil {
|
if result == nil {
|
||||||
t.Fatalf("parseDMARCRecord(%q).SpfAlignment = nil", tt.record)
|
t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record)
|
||||||
}
|
}
|
||||||
if string(*rec.SpfAlignment) != tt.expectedSPF {
|
if string(*result) != tt.expectedAlignment {
|
||||||
t.Errorf("SpfAlignment = %q, want %q", string(*rec.SpfAlignment), tt.expectedSPF)
|
t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
|
||||||
}
|
|
||||||
if rec.DkimAlignment == nil {
|
|
||||||
t.Fatalf("parseDMARCRecord(%q).DkimAlignment = nil", tt.record)
|
|
||||||
}
|
|
||||||
if string(*rec.DkimAlignment) != tt.expectedDKIM {
|
|
||||||
t.Errorf("DkimAlignment = %q, want %q", string(*rec.DkimAlignment), tt.expectedDKIM)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDMARCRecordSubdomainPolicy(t *testing.T) {
|
func TestExtractDMARCDKIMAlignment(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
record string
|
record string
|
||||||
expectedSP *string
|
expectedAlignment string
|
||||||
expectedNP *string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "sp=none, no np",
|
name: "DKIM alignment - strict",
|
||||||
record: "v=DMARC1; p=quarantine; sp=none",
|
record: "v=DMARC1; p=reject; adkim=s",
|
||||||
expectedSP: utils.PtrTo("none"),
|
expectedAlignment: "strict",
|
||||||
expectedNP: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "sp=reject, np=reject",
|
name: "DKIM alignment - relaxed (explicit)",
|
||||||
record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100",
|
record: "v=DMARC1; p=reject; adkim=r",
|
||||||
expectedSP: utils.PtrTo("quarantine"),
|
expectedAlignment: "relaxed",
|
||||||
expectedNP: utils.PtrTo("reject"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No sp or np (both default)",
|
name: "DKIM alignment - relaxed (default, not specified)",
|
||||||
record: "v=DMARC1; p=quarantine",
|
record: "v=DMARC1; p=none",
|
||||||
expectedSP: nil,
|
expectedAlignment: "relaxed",
|
||||||
expectedNP: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "np=quarantine, no sp",
|
name: "Both alignments specified - check DKIM strict",
|
||||||
record: "v=DMARC1; p=reject; np=quarantine",
|
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
|
||||||
expectedSP: nil,
|
expectedAlignment: "strict",
|
||||||
expectedNP: utils.PtrTo("quarantine"),
|
},
|
||||||
|
{
|
||||||
|
name: "Both alignments specified - check DKIM relaxed",
|
||||||
|
record: "v=DMARC1; p=quarantine; aspf=s; adkim=r",
|
||||||
|
expectedAlignment: "relaxed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Complex record with DKIM strict",
|
||||||
|
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100",
|
||||||
|
expectedAlignment: "strict",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -528,63 +208,134 @@ func TestParseDMARCRecordSubdomainPolicy(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
rec := analyzer.parseDMARCRecord("example.com", tt.record)
|
result := analyzer.extractDMARCDKIMAlignment(tt.record)
|
||||||
if tt.expectedSP == nil {
|
if result == nil {
|
||||||
if rec.SubdomainPolicy != nil {
|
t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record)
|
||||||
t.Errorf("parseDMARCRecord(%q).SubdomainPolicy = %v, want nil", tt.record, *rec.SubdomainPolicy)
|
}
|
||||||
|
if string(*result) != tt.expectedAlignment {
|
||||||
|
t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractDMARCSubdomainPolicy(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
record string
|
||||||
|
expectedPolicy *string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Subdomain policy - none",
|
||||||
|
record: "v=DMARC1; p=quarantine; sp=none",
|
||||||
|
expectedPolicy: api.PtrTo("none"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Subdomain policy - quarantine",
|
||||||
|
record: "v=DMARC1; p=reject; sp=quarantine",
|
||||||
|
expectedPolicy: api.PtrTo("quarantine"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Subdomain policy - reject",
|
||||||
|
record: "v=DMARC1; p=quarantine; sp=reject",
|
||||||
|
expectedPolicy: api.PtrTo("reject"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No subdomain policy specified (defaults to main policy)",
|
||||||
|
record: "v=DMARC1; p=quarantine",
|
||||||
|
expectedPolicy: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Complex record with subdomain policy",
|
||||||
|
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100",
|
||||||
|
expectedPolicy: api.PtrTo("quarantine"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.extractDMARCSubdomainPolicy(tt.record)
|
||||||
|
if tt.expectedPolicy == nil {
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if rec.SubdomainPolicy == nil {
|
if result == nil {
|
||||||
t.Fatalf("parseDMARCRecord(%q).SubdomainPolicy = nil, want %q", tt.record, *tt.expectedSP)
|
t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy)
|
||||||
}
|
}
|
||||||
if string(*rec.SubdomainPolicy) != *tt.expectedSP {
|
if string(*result) != *tt.expectedPolicy {
|
||||||
t.Errorf("SubdomainPolicy = %q, want %q", string(*rec.SubdomainPolicy), *tt.expectedSP)
|
t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy)
|
||||||
}
|
|
||||||
}
|
|
||||||
if tt.expectedNP == nil {
|
|
||||||
if rec.NonexistentSubdomainPolicy != nil {
|
|
||||||
t.Errorf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = %v, want nil", tt.record, *rec.NonexistentSubdomainPolicy)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if rec.NonexistentSubdomainPolicy == nil {
|
|
||||||
t.Fatalf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = nil, want %q", tt.record, *tt.expectedNP)
|
|
||||||
}
|
|
||||||
if string(*rec.NonexistentSubdomainPolicy) != *tt.expectedNP {
|
|
||||||
t.Errorf("NonexistentSubdomainPolicy = %q, want %q", string(*rec.NonexistentSubdomainPolicy), *tt.expectedNP)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDMARCRecordPercentage(t *testing.T) {
|
func TestExtractDMARCPercentage(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
record string
|
record string
|
||||||
expectedPercentage *int
|
expectedPercentage *int
|
||||||
}{
|
}{
|
||||||
{name: "pct=100", record: "v=DMARC1; p=quarantine; pct=100", expectedPercentage: utils.PtrTo(100)},
|
{
|
||||||
{name: "pct=50", record: "v=DMARC1; p=quarantine; pct=50", expectedPercentage: utils.PtrTo(50)},
|
name: "Percentage - 100",
|
||||||
{name: "pct=0", record: "v=DMARC1; p=none; pct=0", expectedPercentage: utils.PtrTo(0)},
|
record: "v=DMARC1; p=quarantine; pct=100",
|
||||||
{name: "no pct", record: "v=DMARC1; p=quarantine", expectedPercentage: nil},
|
expectedPercentage: api.PtrTo(100),
|
||||||
{name: "pct=150 ignored", record: "v=DMARC1; p=quarantine; pct=150", expectedPercentage: nil},
|
},
|
||||||
|
{
|
||||||
|
name: "Percentage - 50",
|
||||||
|
record: "v=DMARC1; p=quarantine; pct=50",
|
||||||
|
expectedPercentage: api.PtrTo(50),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Percentage - 25",
|
||||||
|
record: "v=DMARC1; p=reject; pct=25",
|
||||||
|
expectedPercentage: api.PtrTo(25),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Percentage - 0",
|
||||||
|
record: "v=DMARC1; p=none; pct=0",
|
||||||
|
expectedPercentage: api.PtrTo(0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No percentage specified (defaults to 100)",
|
||||||
|
record: "v=DMARC1; p=quarantine",
|
||||||
|
expectedPercentage: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Complex record with percentage",
|
||||||
|
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75",
|
||||||
|
expectedPercentage: api.PtrTo(75),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid percentage > 100 (ignored)",
|
||||||
|
record: "v=DMARC1; p=quarantine; pct=150",
|
||||||
|
expectedPercentage: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid percentage < 0 (ignored)",
|
||||||
|
record: "v=DMARC1; p=quarantine; pct=-10",
|
||||||
|
expectedPercentage: nil,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := analyzer.parseDMARCRecord("example.com", tt.record).Percentage
|
result := analyzer.extractDMARCPercentage(tt.record)
|
||||||
if tt.expectedPercentage == nil {
|
if tt.expectedPercentage == nil {
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Errorf("parseDMARCRecord(%q).Percentage = %d, want nil", tt.record, *result)
|
t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
t.Fatalf("parseDMARCRecord(%q).Percentage = nil, want %d", tt.record, *tt.expectedPercentage)
|
t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage)
|
||||||
}
|
}
|
||||||
if *result != *tt.expectedPercentage {
|
if *result != *tt.expectedPercentage {
|
||||||
t.Errorf("parseDMARCRecord(%q).Percentage = %d, want %d", tt.record, *result, *tt.expectedPercentage)
|
t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,8 @@ package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"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,23 +62,8 @@ func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) {
|
||||||
return ptrNames, forwardIPs
|
return ptrNames, forwardIPs
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkHeloPtrMatch reports whether the announced HELO hostname matches one of
|
|
||||||
// the sender's PTR records (case-insensitive, trailing dot ignored).
|
|
||||||
func checkHeloPtrMatch(helo string, ptrRecords []string) bool {
|
|
||||||
helo = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(helo)), ".")
|
|
||||||
if helo == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, ptr := range ptrRecords {
|
|
||||||
if strings.TrimSuffix(strings.ToLower(ptr), ".") == helo {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -89,11 +73,6 @@ func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP stri
|
||||||
score -= 15
|
score -= 15
|
||||||
}
|
}
|
||||||
|
|
||||||
// Penalty when the announced HELO name doesn't match the PTR hostname
|
|
||||||
if results.HeloPtrMatch != nil && !*results.HeloPtrMatch {
|
|
||||||
score -= 15
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional 50 points for forward-confirmed reverse DNS (FCrDNS)
|
// Additional 50 points for forward-confirmed reverse DNS (FCrDNS)
|
||||||
// This means the PTR hostname resolves back to IPs that include the original sender IP
|
// This means the PTR hostname resolves back to IPs that include the original sender IP
|
||||||
if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" {
|
if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" {
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2025-2026 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCheckHeloPtrMatch(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
helo string
|
|
||||||
ptrRecords []string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"exact match", "mail.example.com", []string{"mail.example.com"}, true},
|
|
||||||
{"case insensitive", "Mail.Example.COM", []string{"mail.example.com"}, true},
|
|
||||||
{"trailing dot ignored", "mail.example.com.", []string{"mail.example.com"}, true},
|
|
||||||
{"mismatch", "relay.example.org", []string{"mail.example.com"}, false},
|
|
||||||
{"match among several", "smtp.example.com", []string{"mail.example.com", "smtp.example.com"}, true},
|
|
||||||
{"empty helo", "", []string{"mail.example.com"}, false},
|
|
||||||
{"no ptr records", "mail.example.com", nil, false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := checkHeloPtrMatch(tt.helo, tt.ptrRecords); got != tt.want {
|
|
||||||
t.Errorf("checkHeloPtrMatch(%q, %v) = %v, want %v", tt.helo, tt.ptrRecords, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculatePTRScoreHeloMismatch(t *testing.T) {
|
|
||||||
d := NewDNSAnalyzer(0)
|
|
||||||
senderIP := "80.67.179.207"
|
|
||||||
ptr := []string{"mail.example.com"}
|
|
||||||
forward := []string{senderIP}
|
|
||||||
|
|
||||||
matchTrue := true
|
|
||||||
matchFalse := false
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
results *model.DNSResults
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "helo matches ptr - no penalty (PTR+FCrDNS)",
|
|
||||||
results: &model.DNSResults{
|
|
||||||
PtrRecords: &ptr,
|
|
||||||
PtrForwardRecords: &forward,
|
|
||||||
HeloPtrMatch: &matchTrue,
|
|
||||||
},
|
|
||||||
want: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "helo mismatch - 15 point penalty",
|
|
||||||
results: &model.DNSResults{
|
|
||||||
PtrRecords: &ptr,
|
|
||||||
PtrForwardRecords: &forward,
|
|
||||||
HeloPtrMatch: &matchFalse,
|
|
||||||
},
|
|
||||||
want: 85,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no helo info - no penalty",
|
|
||||||
results: &model.DNSResults{
|
|
||||||
PtrRecords: &ptr,
|
|
||||||
PtrForwardRecords: &forward,
|
|
||||||
},
|
|
||||||
want: 100,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := d.calculatePTRScore(tt.results, senderIP); got != tt.want {
|
|
||||||
t.Errorf("calculatePTRScore() = %d, want %d", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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: %s", formatDNSError(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 {
|
||||||
|
|
|
||||||
|
|
@ -23,22 +23,9 @@ package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"net"
|
"net"
|
||||||
)
|
)
|
||||||
|
|
||||||
// formatDNSError renders a resolution error without exposing the upstream
|
|
||||||
// resolver address that net.DNSError.Error() normally appends as " on <addr>".
|
|
||||||
func formatDNSError(err error) string {
|
|
||||||
var dnsErr *net.DNSError
|
|
||||||
if errors.As(err, &dnsErr) {
|
|
||||||
sanitized := *dnsErr
|
|
||||||
sanitized.Server = ""
|
|
||||||
return sanitized.Error()
|
|
||||||
}
|
|
||||||
return err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNSResolver defines the interface for DNS resolution operations.
|
// DNSResolver defines the interface for DNS resolution operations.
|
||||||
// This interface abstracts DNS lookups to allow for custom implementations,
|
// This interface abstracts DNS lookups to allow for custom implementations,
|
||||||
// such as mock resolvers for testing or caching resolvers for performance.
|
// such as mock resolvers for testing or caching resolvers for performance.
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2025-2026 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ReturnOKDomain.Status values, matching the schema enum. Kept as a plain string
|
|
||||||
// in the generated model (x-go-type) to avoid colliding with other "pass"/"fail"
|
|
||||||
// enums in the global enum namespace.
|
|
||||||
const (
|
|
||||||
returnOKStatusPass = "pass"
|
|
||||||
returnOKStatusWarn = "warn"
|
|
||||||
returnOKStatusFail = "fail"
|
|
||||||
)
|
|
||||||
|
|
||||||
// domainCanReceive reports whether a domain can accept mail, looking up records
|
|
||||||
// in the same order as Fastmail's ReturnOK milter: MX first, then A/AAAA.
|
|
||||||
func (d *DNSAnalyzer) domainCanReceive(domain string) (hasMX, hasAddress bool) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if mxRecords, err := d.resolver.LookupMX(ctx, domain); err == nil && len(mxRecords) > 0 {
|
|
||||||
return true, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if addrs, err := d.resolver.LookupHost(ctx, domain); err == nil && len(addrs) > 0 {
|
|
||||||
return false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkReturnOKDomain verifies that a domain can receive replies/bounces.
|
|
||||||
// It checks the domain itself, then falls back to its organizational domain
|
|
||||||
// (when different) the same way the ReturnOK milter retries the org domain.
|
|
||||||
func (d *DNSAnalyzer) checkReturnOKDomain(domain, orgDomain string) *model.ReturnOKDomain {
|
|
||||||
if domain == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &model.ReturnOKDomain{Domain: domain}
|
|
||||||
|
|
||||||
hasMX, hasAddress := d.domainCanReceive(domain)
|
|
||||||
|
|
||||||
// Fall back to the organizational domain when the domain itself has nothing.
|
|
||||||
if !hasMX && !hasAddress && orgDomain != "" && orgDomain != domain {
|
|
||||||
if orgMX, orgAddr := d.domainCanReceive(orgDomain); orgMX || orgAddr {
|
|
||||||
hasMX, hasAddress = orgMX, orgAddr
|
|
||||||
result.OrgDomain = utils.PtrTo(orgDomain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.HasMx = utils.PtrTo(hasMX)
|
|
||||||
result.HasAddress = utils.PtrTo(hasAddress)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case hasMX:
|
|
||||||
result.Status = returnOKStatusPass
|
|
||||||
case hasAddress:
|
|
||||||
result.Status = returnOKStatusWarn
|
|
||||||
default:
|
|
||||||
result.Status = returnOKStatusFail
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateReturnOKPenalty returns a non-positive value: each sender domain that
|
|
||||||
// can receive neither replies nor bounces (status=fail) costs points, since
|
|
||||||
// those messages would be silently lost.
|
|
||||||
func calculateReturnOKPenalty(results *model.DNSResults) (penalty int) {
|
|
||||||
if results.ReturnOk == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
for _, dom := range []*model.ReturnOKDomain{results.ReturnOk.From, results.ReturnOk.ReturnPath} {
|
|
||||||
if dom != nil && dom.Status == returnOKStatusFail {
|
|
||||||
penalty -= 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// orgDomainOrEmpty dereferences an optional organizational domain pointer.
|
|
||||||
func orgDomainOrEmpty(orgDomain *string) string {
|
|
||||||
if orgDomain == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *orgDomain
|
|
||||||
}
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2025-2026 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// returnOKMockResolver lets tests control MX and host (A/AAAA) lookups per domain.
|
|
||||||
type returnOKMockResolver struct {
|
|
||||||
mx map[string][]*net.MX
|
|
||||||
hosts map[string][]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *returnOKMockResolver) LookupMX(_ context.Context, name string) ([]*net.MX, error) {
|
|
||||||
if recs, ok := m.mx[name]; ok {
|
|
||||||
return recs, nil
|
|
||||||
}
|
|
||||||
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *returnOKMockResolver) LookupHost(_ context.Context, host string) ([]string, error) {
|
|
||||||
if recs, ok := m.hosts[host]; ok {
|
|
||||||
return recs, nil
|
|
||||||
}
|
|
||||||
return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *returnOKMockResolver) LookupTXT(_ context.Context, _ string) ([]string, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *returnOKMockResolver) LookupAddr(_ context.Context, _ string) ([]string, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckReturnOKDomain(t *testing.T) {
|
|
||||||
mx := []*net.MX{{Host: "mail.example.com.", Pref: 10}}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
domain string
|
|
||||||
orgDomain string
|
|
||||||
resolver *returnOKMockResolver
|
|
||||||
wantStatus string
|
|
||||||
wantHasMX bool
|
|
||||||
wantHasAddr bool
|
|
||||||
wantOrgDomain string // "" means OrgDomain should be nil
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "domain with MX passes",
|
|
||||||
domain: "example.com",
|
|
||||||
resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}},
|
|
||||||
wantStatus: returnOKStatusPass,
|
|
||||||
wantHasMX: true,
|
|
||||||
wantHasAddr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no MX but A/AAAA warns",
|
|
||||||
domain: "example.com",
|
|
||||||
resolver: &returnOKMockResolver{hosts: map[string][]string{"example.com": {"192.0.2.1"}}},
|
|
||||||
wantStatus: returnOKStatusWarn,
|
|
||||||
wantHasMX: false,
|
|
||||||
wantHasAddr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fallback to org domain MX",
|
|
||||||
domain: "sub.example.com",
|
|
||||||
orgDomain: "example.com",
|
|
||||||
resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}},
|
|
||||||
wantStatus: returnOKStatusPass,
|
|
||||||
wantHasMX: true,
|
|
||||||
wantHasAddr: false,
|
|
||||||
wantOrgDomain: "example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nothing anywhere fails",
|
|
||||||
domain: "example.com",
|
|
||||||
orgDomain: "example.com",
|
|
||||||
resolver: &returnOKMockResolver{},
|
|
||||||
wantStatus: returnOKStatusFail,
|
|
||||||
wantHasMX: false,
|
|
||||||
wantHasAddr: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
d := NewDNSAnalyzerWithResolver(5*time.Second, tt.resolver)
|
|
||||||
got := d.checkReturnOKDomain(tt.domain, tt.orgDomain)
|
|
||||||
if got == nil {
|
|
||||||
t.Fatalf("checkReturnOKDomain returned nil")
|
|
||||||
}
|
|
||||||
if got.Status != tt.wantStatus {
|
|
||||||
t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus)
|
|
||||||
}
|
|
||||||
if got.HasMx == nil || *got.HasMx != tt.wantHasMX {
|
|
||||||
t.Errorf("HasMx = %v, want %v", got.HasMx, tt.wantHasMX)
|
|
||||||
}
|
|
||||||
if got.HasAddress == nil || *got.HasAddress != tt.wantHasAddr {
|
|
||||||
t.Errorf("HasAddress = %v, want %v", got.HasAddress, tt.wantHasAddr)
|
|
||||||
}
|
|
||||||
if tt.wantOrgDomain == "" {
|
|
||||||
if got.OrgDomain != nil {
|
|
||||||
t.Errorf("OrgDomain = %v, want nil", *got.OrgDomain)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if got.OrgDomain == nil || *got.OrgDomain != tt.wantOrgDomain {
|
|
||||||
t.Errorf("OrgDomain = %v, want %q", got.OrgDomain, tt.wantOrgDomain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckReturnOKDomainEmpty(t *testing.T) {
|
|
||||||
d := NewDNSAnalyzerWithResolver(5*time.Second, &returnOKMockResolver{})
|
|
||||||
if got := d.checkReturnOKDomain("", ""); got != nil {
|
|
||||||
t.Errorf("checkReturnOKDomain(\"\") = %v, want nil", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculateReturnOKPenalty(t *testing.T) {
|
|
||||||
fail := &model.ReturnOKDomain{Domain: "a.example", Status: returnOKStatusFail}
|
|
||||||
pass := &model.ReturnOKDomain{Domain: "b.example", Status: returnOKStatusPass}
|
|
||||||
warn := &model.ReturnOKDomain{Domain: "c.example", Status: returnOKStatusWarn}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
results *model.DNSResults
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"nil return_ok", &model.DNSResults{}, 0},
|
|
||||||
{"both pass", &model.DNSResults{ReturnOk: &model.ReturnOK{From: pass, ReturnPath: pass}}, 0},
|
|
||||||
{"warn is not penalised", &model.DNSResults{ReturnOk: &model.ReturnOK{From: warn}}, 0},
|
|
||||||
{"one fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: pass}}, -10},
|
|
||||||
{"both fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: fail}}, -20},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := calculateReturnOKPenalty(tt.results); got != tt.want {
|
|
||||||
t.Errorf("calculateReturnOKPenalty() = %d, want %d", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord {
|
||||||
visited := make(map[string]bool)
|
visited := make(map[string]bool)
|
||||||
return d.resolveSPFRecords(domain, visited, 0, true)
|
return d.resolveSPFRecords(domain, visited, 0, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSPFRecords recursively resolves SPF records including include: directives
|
// resolveSPFRecords recursively resolves SPF records including include: directives
|
||||||
// isMainRecord indicates if this is the primary domain's record (not an included one)
|
// isMainRecord indicates if this is the primary domain's record (not an included one)
|
||||||
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]model.SPFRecord {
|
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]api.SPFRecord {
|
||||||
const maxDepth = 10 // Prevent infinite recursion
|
const maxDepth = 10 // Prevent infinite recursion
|
||||||
|
|
||||||
if depth > maxDepth {
|
if depth > maxDepth {
|
||||||
return &[]model.SPFRecord{
|
return &[]api.SPFRecord{
|
||||||
{
|
{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("Maximum SPF include depth exceeded"),
|
Error: api.PtrTo("Maximum SPF include depth exceeded"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent circular references
|
// Prevent circular references
|
||||||
if visited[domain] {
|
if visited[domain] {
|
||||||
return &[]model.SPFRecord{}
|
return &[]api.SPFRecord{}
|
||||||
}
|
}
|
||||||
visited[domain] = true
|
visited[domain] = true
|
||||||
|
|
||||||
|
|
@ -63,11 +62,11 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
|
||||||
|
|
||||||
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
|
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &[]model.SPFRecord{
|
return &[]api.SPFRecord{
|
||||||
{
|
{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %s", formatDNSError(err))),
|
Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -83,23 +82,23 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
if spfCount == 0 {
|
if spfCount == 0 {
|
||||||
return &[]model.SPFRecord{
|
return &[]api.SPFRecord{
|
||||||
{
|
{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("No SPF record found"),
|
Error: api.PtrTo("No SPF record found"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []model.SPFRecord
|
var results []api.SPFRecord
|
||||||
|
|
||||||
if spfCount > 1 {
|
if spfCount > 1 {
|
||||||
results = append(results, model.SPFRecord{
|
results = append(results, api.SPFRecord{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Record: &spfRecord,
|
Record: &spfRecord,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo("Multiple SPF records found (RFC violation)"),
|
Error: api.PtrTo("Multiple SPF records found (RFC violation)"),
|
||||||
})
|
})
|
||||||
return &results
|
return &results
|
||||||
}
|
}
|
||||||
|
|
@ -108,28 +107,28 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
|
||||||
validationErr := d.validateSPF(spfRecord, isMainRecord)
|
validationErr := d.validateSPF(spfRecord, isMainRecord)
|
||||||
|
|
||||||
// Extract the "all" mechanism qualifier
|
// Extract the "all" mechanism qualifier
|
||||||
var allQualifier *model.SPFRecordAllQualifier
|
var allQualifier *api.SPFRecordAllQualifier
|
||||||
var errMsg *string
|
var errMsg *string
|
||||||
|
|
||||||
if validationErr != nil {
|
if validationErr != nil {
|
||||||
errMsg = utils.PtrTo(validationErr.Error())
|
errMsg = api.PtrTo(validationErr.Error())
|
||||||
} else {
|
} else {
|
||||||
// Extract qualifier from the "all" mechanism
|
// Extract qualifier from the "all" mechanism
|
||||||
if strings.HasSuffix(spfRecord, " -all") {
|
if strings.HasSuffix(spfRecord, " -all") {
|
||||||
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("-"))
|
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-"))
|
||||||
} else if strings.HasSuffix(spfRecord, " ~all") {
|
} else if strings.HasSuffix(spfRecord, " ~all") {
|
||||||
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("~"))
|
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~"))
|
||||||
} else if strings.HasSuffix(spfRecord, " +all") {
|
} else if strings.HasSuffix(spfRecord, " +all") {
|
||||||
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
|
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
|
||||||
} else if strings.HasSuffix(spfRecord, " ?all") {
|
} else if strings.HasSuffix(spfRecord, " ?all") {
|
||||||
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("?"))
|
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?"))
|
||||||
} else if strings.HasSuffix(spfRecord, " all") {
|
} else if strings.HasSuffix(spfRecord, " all") {
|
||||||
// Implicit + qualifier (default)
|
// Implicit + qualifier (default)
|
||||||
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
|
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results = append(results, model.SPFRecord{
|
results = append(results, api.SPFRecord{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Record: &spfRecord,
|
Record: &spfRecord,
|
||||||
Valid: validationErr == nil,
|
Valid: validationErr == nil,
|
||||||
|
|
@ -302,7 +301,7 @@ func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool {
|
||||||
return strings.HasSuffix(record, " -all")
|
return strings.HasSuffix(record, " -all")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DNSAnalyzer) calculateSPFScore(results *model.DNSResults) (score int) {
|
func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) {
|
||||||
// SPF is essential for email authentication
|
// SPF is essential for email authentication
|
||||||
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
|
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
|
||||||
// Find the main SPF record by skipping redirects
|
// Find the main SPF record by skipping redirects
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,12 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/net/publicsuffix"
|
"golang.org/x/net/publicsuffix"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HeaderAnalyzer analyzes email header quality and structure
|
// HeaderAnalyzer analyzes email header quality and structure
|
||||||
|
|
@ -45,7 +43,7 @@ func NewHeaderAnalyzer() *HeaderAnalyzer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateHeaderScore evaluates email structural quality from header analysis
|
// CalculateHeaderScore evaluates email structural quality from header analysis
|
||||||
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *model.HeaderAnalysis) (int, rune) {
|
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int, rune) {
|
||||||
if analysis == nil || analysis.Headers == nil {
|
if analysis == nil || analysis.Headers == nil {
|
||||||
return 0, ' '
|
return 0, ' '
|
||||||
}
|
}
|
||||||
|
|
@ -189,7 +187,7 @@ func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNoReplyAddress checks if a header check represents a no-reply email address
|
// isNoReplyAddress checks if a header check represents a no-reply email address
|
||||||
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck model.HeaderCheck) bool {
|
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool {
|
||||||
if !headerCheck.Present || headerCheck.Value == nil {
|
if !headerCheck.Present || headerCheck.Value == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -245,18 +243,18 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateHeaderAnalysis creates structured header analysis from email
|
// GenerateHeaderAnalysis creates structured header analysis from email
|
||||||
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *model.AuthenticationResults) *model.HeaderAnalysis {
|
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis {
|
||||||
if email == nil {
|
if email == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
analysis := &model.HeaderAnalysis{}
|
analysis := &api.HeaderAnalysis{}
|
||||||
|
|
||||||
// Check for proper MIME structure
|
// Check for proper MIME structure
|
||||||
analysis.HasMimeStructure = utils.PtrTo(len(email.Parts) > 0)
|
analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0)
|
||||||
|
|
||||||
// Initialize headers map
|
// Initialize headers map
|
||||||
headers := make(map[string]model.HeaderCheck)
|
headers := make(map[string]api.HeaderCheck)
|
||||||
|
|
||||||
// Check required headers
|
// Check required headers
|
||||||
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
|
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
|
||||||
|
|
@ -310,12 +308,12 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkHeader checks if a header is present and valid
|
// checkHeader checks if a header is present and valid
|
||||||
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *model.HeaderCheck {
|
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck {
|
||||||
value := email.GetHeaderValue(headerName)
|
value := email.GetHeaderValue(headerName)
|
||||||
present := email.HasHeader(headerName) && value != ""
|
present := email.HasHeader(headerName) && value != ""
|
||||||
|
|
||||||
importanceEnum := model.HeaderCheckImportance(importance)
|
importanceEnum := api.HeaderCheckImportance(importance)
|
||||||
check := &model.HeaderCheck{
|
check := &api.HeaderCheck{
|
||||||
Present: present,
|
Present: present,
|
||||||
Importance: &importanceEnum,
|
Importance: &importanceEnum,
|
||||||
}
|
}
|
||||||
|
|
@ -376,10 +374,10 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
|
||||||
}
|
}
|
||||||
|
|
||||||
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
|
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
|
||||||
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *model.AuthenticationResults) *model.DomainAlignment {
|
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment {
|
||||||
alignment := &model.DomainAlignment{
|
alignment := &api.DomainAlignment{
|
||||||
Aligned: utils.PtrTo(true),
|
Aligned: api.PtrTo(true),
|
||||||
RelaxedAligned: utils.PtrTo(true),
|
RelaxedAligned: api.PtrTo(true),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract From domain
|
// Extract From domain
|
||||||
|
|
@ -389,7 +387,7 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
|
||||||
if domain != "" {
|
if domain != "" {
|
||||||
alignment.FromDomain = &domain
|
alignment.FromDomain = &domain
|
||||||
// Extract organizational domain
|
// Extract organizational domain
|
||||||
orgDomain := getOrganizationalDomain(domain)
|
orgDomain := h.getOrganizationalDomain(domain)
|
||||||
alignment.FromOrgDomain = &orgDomain
|
alignment.FromOrgDomain = &orgDomain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -401,19 +399,19 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
|
||||||
if domain != "" {
|
if domain != "" {
|
||||||
alignment.ReturnPathDomain = &domain
|
alignment.ReturnPathDomain = &domain
|
||||||
// Extract organizational domain
|
// Extract organizational domain
|
||||||
orgDomain := getOrganizationalDomain(domain)
|
orgDomain := h.getOrganizationalDomain(domain)
|
||||||
alignment.ReturnPathOrgDomain = &orgDomain
|
alignment.ReturnPathOrgDomain = &orgDomain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract DKIM domains from authentication results
|
// Extract DKIM domains from authentication results
|
||||||
var dkimDomains []model.DKIMDomainInfo
|
var dkimDomains []api.DKIMDomainInfo
|
||||||
if authResults != nil && authResults.Dkim != nil {
|
if authResults != nil && authResults.Dkim != nil {
|
||||||
for _, dkim := range *authResults.Dkim {
|
for _, dkim := range *authResults.Dkim {
|
||||||
if dkim.Domain != nil && *dkim.Domain != "" {
|
if dkim.Domain != nil && *dkim.Domain != "" {
|
||||||
domain := *dkim.Domain
|
domain := *dkim.Domain
|
||||||
orgDomain := getOrganizationalDomain(domain)
|
orgDomain := h.getOrganizationalDomain(domain)
|
||||||
dkimDomains = append(dkimDomains, model.DKIMDomainInfo{
|
dkimDomains = append(dkimDomains, api.DKIMDomainInfo{
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
OrgDomain: orgDomain,
|
OrgDomain: orgDomain,
|
||||||
})
|
})
|
||||||
|
|
@ -543,7 +541,7 @@ func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
|
||||||
// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name
|
// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name
|
||||||
// using the Public Suffix List (PSL) to correctly handle multi-level TLDs.
|
// using the Public Suffix List (PSL) to correctly handle multi-level TLDs.
|
||||||
// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk
|
// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk
|
||||||
func getOrganizationalDomain(domain string) string {
|
func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string {
|
||||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
|
||||||
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)
|
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)
|
||||||
|
|
@ -562,18 +560,18 @@ func getOrganizationalDomain(domain string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// findHeaderIssues identifies issues with headers
|
// findHeaderIssues identifies issues with headers
|
||||||
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIssue {
|
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue {
|
||||||
var issues []model.HeaderIssue
|
var issues []api.HeaderIssue
|
||||||
|
|
||||||
// Check for missing required headers
|
// Check for missing required headers
|
||||||
requiredHeaders := []string{"From", "Date", "Message-ID"}
|
requiredHeaders := []string{"From", "Date", "Message-ID"}
|
||||||
for _, header := range requiredHeaders {
|
for _, header := range requiredHeaders {
|
||||||
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
||||||
issues = append(issues, model.HeaderIssue{
|
issues = append(issues, api.HeaderIssue{
|
||||||
Header: header,
|
Header: header,
|
||||||
Severity: model.HeaderIssueSeverityCritical,
|
Severity: api.HeaderIssueSeverityCritical,
|
||||||
Message: fmt.Sprintf("Required header '%s' is missing", header),
|
Message: fmt.Sprintf("Required header '%s' is missing", header),
|
||||||
Advice: utils.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
|
Advice: api.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -581,63 +579,19 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIss
|
||||||
// Check Message-ID format
|
// Check Message-ID format
|
||||||
messageID := email.GetHeaderValue("Message-ID")
|
messageID := email.GetHeaderValue("Message-ID")
|
||||||
if messageID != "" && !h.isValidMessageID(messageID) {
|
if messageID != "" && !h.isValidMessageID(messageID) {
|
||||||
issues = append(issues, model.HeaderIssue{
|
issues = append(issues, api.HeaderIssue{
|
||||||
Header: "Message-ID",
|
Header: "Message-ID",
|
||||||
Severity: model.HeaderIssueSeverityMedium,
|
Severity: api.HeaderIssueSeverityMedium,
|
||||||
Message: "Message-ID format is invalid",
|
Message: "Message-ID format is invalid",
|
||||||
Advice: utils.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
|
Advice: api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for fake reply/forward: Subject has Re:/Fwd: prefix but no thread headers
|
|
||||||
subject := email.GetHeaderValue("Subject")
|
|
||||||
if h.hasReplyPrefix(subject) && !email.HasHeader("References") && !email.HasHeader("In-Reply-To") {
|
|
||||||
issues = append(issues, model.HeaderIssue{
|
|
||||||
Header: "Subject",
|
|
||||||
Severity: model.HeaderIssueSeverityHigh,
|
|
||||||
Message: "Subject indicates a reply or forward but no References or In-Reply-To header is present",
|
|
||||||
Advice: utils.PtrTo("Remove the Re:/Fwd: prefix from the subject, or add References/In-Reply-To headers if this is a genuine reply"),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return issues
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasReplyPrefix reports whether a subject line starts with a reply or forward prefix.
|
|
||||||
func (h *HeaderAnalyzer) hasReplyPrefix(subject string) bool {
|
|
||||||
// Normalize: collapse leading whitespace and make comparison case-insensitive
|
|
||||||
s := strings.ToLower(strings.TrimSpace(subject))
|
|
||||||
|
|
||||||
prefixes := []string{
|
|
||||||
"re:", // English / universal
|
|
||||||
"fwd:", // English forward
|
|
||||||
"fw:", // English forward (short)
|
|
||||||
"aw:", // German Antwort
|
|
||||||
"wg:", // German Weitergeleitet
|
|
||||||
"sv:", // Scandinavian Svar
|
|
||||||
"vs:", // Finnish Vastaus / Norwegian
|
|
||||||
"ref:", // Some clients
|
|
||||||
"rép:", // French Réponse
|
|
||||||
"tr:", // French Transfert
|
|
||||||
"odp:", // Polish Odpowiedź
|
|
||||||
"ynt:", // Turkish Yanıt
|
|
||||||
"res:", // Portuguese/Spanish Resposta/Respuesta
|
|
||||||
"enc:", // Spanish Enviado/Reenviado
|
|
||||||
"vl:", // Dutch Verwijzing
|
|
||||||
"antw:", // Dutch Antwoord
|
|
||||||
"rv:", // Norwegian/Swedish
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range prefixes {
|
|
||||||
if strings.HasPrefix(s, p) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseReceivedChain extracts the chain of Received headers from an email
|
// parseReceivedChain extracts the chain of Received headers from an email
|
||||||
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop {
|
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop {
|
||||||
if email == nil || email.Header == nil {
|
if email == nil || email.Header == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -647,7 +601,7 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.Receive
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var chain []model.ReceivedHop
|
var chain []api.ReceivedHop
|
||||||
|
|
||||||
for _, receivedValue := range receivedHeaders {
|
for _, receivedValue := range receivedHeaders {
|
||||||
hop := h.parseReceivedHeader(receivedValue)
|
hop := h.parseReceivedHeader(receivedValue)
|
||||||
|
|
@ -660,8 +614,8 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.Receive
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseReceivedHeader parses a single Received header value
|
// parseReceivedHeader parses a single Received header value
|
||||||
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.ReceivedHop {
|
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.ReceivedHop {
|
||||||
hop := &model.ReceivedHop{}
|
hop := &api.ReceivedHop{}
|
||||||
|
|
||||||
// Normalize whitespace - Received headers can span multiple lines
|
// Normalize whitespace - Received headers can span multiple lines
|
||||||
normalized := strings.Join(strings.Fields(receivedValue), " ")
|
normalized := strings.Join(strings.Fields(receivedValue), " ")
|
||||||
|
|
@ -738,50 +692,5 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.Receiv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract TLS details from the Received header parentheticals
|
|
||||||
// (e.g. "(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) ...)")
|
|
||||||
hop.Tls = parseReceivedTLS(normalized)
|
|
||||||
|
|
||||||
return hop
|
return hop
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseReceivedTLS extracts TLS connection details from a normalized Received header value.
|
|
||||||
// Returns nil when the hop was not encrypted (no TLS version/cipher found).
|
|
||||||
func parseReceivedTLS(normalized string) *model.TLSInfo {
|
|
||||||
tls := &model.TLSInfo{}
|
|
||||||
found := false
|
|
||||||
|
|
||||||
// TLS protocol version, e.g. "using TLSv1.3"
|
|
||||||
if matches := regexp.MustCompile(`(?i)using\s+(TLSv[0-9.]+|SSLv[0-9.]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
|
||||||
tls.Version = &matches[1]
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cipher suite, e.g. "with cipher TLS_AES_256_GCM_SHA384"
|
|
||||||
if matches := regexp.MustCompile(`(?i)with cipher\s+([A-Za-z0-9_-]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
|
||||||
tls.Cipher = &matches[1]
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cipher strength, e.g. "(256/256 bits)"
|
|
||||||
if matches := regexp.MustCompile(`\((\d+)/\d+ bits\)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
|
||||||
if bits, err := strconv.Atoi(matches[1]); err == nil {
|
|
||||||
tls.Bits = &bits
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Certificate verification status. Postfix emits "(verified OK)" when the peer
|
|
||||||
// certificate was trusted, "(not verified)" otherwise. "No client certificate
|
|
||||||
// requested" leaves the field unset (trust is simply not applicable).
|
|
||||||
if regexp.MustCompile(`(?i)verified OK`).MatchString(normalized) {
|
|
||||||
tls.Verified = utils.PtrTo(true)
|
|
||||||
} else if regexp.MustCompile(`(?i)not verified`).MatchString(normalized) {
|
|
||||||
tls.Verified = utils.PtrTo(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tls
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCalculateHeaderScore(t *testing.T) {
|
func TestCalculateHeaderScore(t *testing.T) {
|
||||||
|
|
@ -404,7 +404,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
name string
|
name string
|
||||||
receivedHeaders []string
|
receivedHeaders []string
|
||||||
expectedHops int
|
expectedHops int
|
||||||
validateFirst func(*testing.T, *EmailMessage, []model.ReceivedHop)
|
validateFirst func(*testing.T, *EmailMessage, []api.ReceivedHop)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "No Received headers",
|
name: "No Received headers",
|
||||||
|
|
@ -417,7 +417,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
|
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -450,7 +450,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000",
|
"from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000",
|
||||||
},
|
},
|
||||||
expectedHops: 2,
|
expectedHops: 2,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||||
if len(hops) != 2 {
|
if len(hops) != 2 {
|
||||||
t.Fatalf("Expected 2 hops, got %d", len(hops))
|
t.Fatalf("Expected 2 hops, got %d", len(hops))
|
||||||
}
|
}
|
||||||
|
|
@ -472,7 +472,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
|
"from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -499,7 +499,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
for <test-9a9ce364-c394-4fa9-acef-d46ff2f482bf@deliver.happydomain.org>; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`,
|
for <test-9a9ce364-c394-4fa9-acef-d46ff2f482bf@deliver.happydomain.org>; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`,
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -527,7 +527,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from unknown by localhost",
|
"from unknown by localhost",
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -677,77 +677,6 @@ func TestParseReceivedHeader(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseReceivedTLS(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
receivedValue string
|
|
||||||
expectNil bool
|
|
||||||
expectVersion *string
|
|
||||||
expectCipher *string
|
|
||||||
expectBits *int
|
|
||||||
expectVerified *bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "TLS 1.3 no client certificate",
|
|
||||||
receivedValue: "from mail.example.com (unknown [IPv6:2001:db8::1]) " +
|
|
||||||
"(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) " +
|
|
||||||
"key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256) " +
|
|
||||||
"(No client certificate requested) " +
|
|
||||||
"by mx.example.org (Postfix) with ESMTPSA id 1EFD11611EA; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
|
|
||||||
expectVersion: strPtr("TLSv1.3"),
|
|
||||||
expectCipher: strPtr("TLS_AES_256_GCM_SHA384"),
|
|
||||||
expectBits: intPtr(256),
|
|
||||||
expectVerified: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "TLS with verified client certificate",
|
|
||||||
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) " +
|
|
||||||
"(using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) " +
|
|
||||||
"(Client CN \"example\", Issuer \"CA\" (verified OK)) " +
|
|
||||||
"by mx.receiver.com (Postfix) with ESMTPS id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
expectVersion: strPtr("TLSv1.2"),
|
|
||||||
expectCipher: strPtr("ECDHE-RSA-AES128-GCM-SHA256"),
|
|
||||||
expectBits: intPtr(128),
|
|
||||||
expectVerified: boolPtr(true),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Plaintext (no TLS)",
|
|
||||||
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTP id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
expectNil: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
normalized := strings.Join(strings.Fields(tt.receivedValue), " ")
|
|
||||||
tls := parseReceivedTLS(normalized)
|
|
||||||
|
|
||||||
if tt.expectNil {
|
|
||||||
if tls != nil {
|
|
||||||
t.Fatalf("expected nil TLS info, got %+v", tls)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if tls == nil {
|
|
||||||
t.Fatal("parseReceivedTLS returned nil")
|
|
||||||
}
|
|
||||||
if !equalStrPtr(tls.Version, tt.expectVersion) {
|
|
||||||
t.Errorf("Version = %v, want %v", ptrToStr(tls.Version), ptrToStr(tt.expectVersion))
|
|
||||||
}
|
|
||||||
if !equalStrPtr(tls.Cipher, tt.expectCipher) {
|
|
||||||
t.Errorf("Cipher = %v, want %v", ptrToStr(tls.Cipher), ptrToStr(tt.expectCipher))
|
|
||||||
}
|
|
||||||
if (tls.Bits == nil) != (tt.expectBits == nil) || (tls.Bits != nil && *tls.Bits != *tt.expectBits) {
|
|
||||||
t.Errorf("Bits = %v, want %v", tls.Bits, tt.expectBits)
|
|
||||||
}
|
|
||||||
if (tls.Verified == nil) != (tt.expectVerified == nil) || (tls.Verified != nil && *tls.Verified != *tt.expectVerified) {
|
|
||||||
t.Errorf("Verified = %v, want %v", tls.Verified, tt.expectVerified)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
|
func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
|
||||||
analyzer := NewHeaderAnalyzer()
|
analyzer := NewHeaderAnalyzer()
|
||||||
|
|
||||||
|
|
@ -974,155 +903,11 @@ func TestCheckHeader_DateValidation(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasReplyPrefix(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
subject string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
// Positive cases
|
|
||||||
{"Re: Hello", true},
|
|
||||||
{"RE: Hello", true},
|
|
||||||
{"re: Hello", true},
|
|
||||||
{"Fwd: Hello", true},
|
|
||||||
{"FWD: Hello", true},
|
|
||||||
{"fw: Hello", true},
|
|
||||||
{"FW: Hello", true},
|
|
||||||
{"Aw: Hallo", true},
|
|
||||||
{"WG: Weitergeleitet", true},
|
|
||||||
{"Sv: Hej", true},
|
|
||||||
{"Vs: Vastaus", true},
|
|
||||||
{"Ref: something", true},
|
|
||||||
{"Rép: Bonjour", true},
|
|
||||||
{"TR: Transféré", true},
|
|
||||||
{"Odp: Odpowiedź", true},
|
|
||||||
{"Ynt: Yanıt", true},
|
|
||||||
{"Res: Resposta", true},
|
|
||||||
{"Enc: Reenviado", true},
|
|
||||||
{"Vl: Verwijzing", true},
|
|
||||||
{"Antw: Antwoord", true},
|
|
||||||
{"Rv: Svar", true},
|
|
||||||
// Negative cases
|
|
||||||
{"Hello", false},
|
|
||||||
{"", false},
|
|
||||||
{"react: something", false},
|
|
||||||
{"reference: check this", false},
|
|
||||||
{"Resources available", false},
|
|
||||||
{"Friendly reminder", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewHeaderAnalyzer()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.subject, func(t *testing.T) {
|
|
||||||
result := analyzer.hasReplyPrefix(tt.subject)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("hasReplyPrefix(%q) = %v, want %v", tt.subject, result, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindHeaderIssues_FakeReply(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
headers map[string]string
|
|
||||||
expectIssueType string // non-empty means we expect an issue containing this substring
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Re: subject without thread headers",
|
|
||||||
headers: map[string]string{
|
|
||||||
"From": "sender@example.com",
|
|
||||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
"Message-ID": "<abc@example.com>",
|
|
||||||
"Subject": "Re: Your invoice",
|
|
||||||
},
|
|
||||||
expectIssueType: "References or In-Reply-To",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Fwd: subject without thread headers",
|
|
||||||
headers: map[string]string{
|
|
||||||
"From": "sender@example.com",
|
|
||||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
"Message-ID": "<abc@example.com>",
|
|
||||||
"Subject": "Fwd: Important update",
|
|
||||||
},
|
|
||||||
expectIssueType: "References or In-Reply-To",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Re: subject with References header - no issue",
|
|
||||||
headers: map[string]string{
|
|
||||||
"From": "sender@example.com",
|
|
||||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
"Message-ID": "<abc@example.com>",
|
|
||||||
"Subject": "Re: Your invoice",
|
|
||||||
"References": "<original@example.com>",
|
|
||||||
},
|
|
||||||
expectIssueType: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Re: subject with In-Reply-To only - no issue",
|
|
||||||
headers: map[string]string{
|
|
||||||
"From": "sender@example.com",
|
|
||||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
"Message-ID": "<abc@example.com>",
|
|
||||||
"Subject": "Re: Your invoice",
|
|
||||||
"In-Reply-To": "<original@example.com>",
|
|
||||||
},
|
|
||||||
expectIssueType: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Normal subject without thread headers - no issue",
|
|
||||||
headers: map[string]string{
|
|
||||||
"From": "sender@example.com",
|
|
||||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
"Message-ID": "<abc@example.com>",
|
|
||||||
"Subject": "Your invoice",
|
|
||||||
},
|
|
||||||
expectIssueType: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewHeaderAnalyzer()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
email := &EmailMessage{
|
|
||||||
Header: createHeaderWithFields(tt.headers),
|
|
||||||
}
|
|
||||||
|
|
||||||
issues := analyzer.findHeaderIssues(email)
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, issue := range issues {
|
|
||||||
if strings.Contains(issue.Message, tt.expectIssueType) {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.expectIssueType != "" && !found {
|
|
||||||
t.Errorf("expected issue containing %q, but none found (issues: %v)", tt.expectIssueType, issues)
|
|
||||||
}
|
|
||||||
if tt.expectIssueType == "" {
|
|
||||||
for _, issue := range issues {
|
|
||||||
if strings.Contains(issue.Message, "References or In-Reply-To") {
|
|
||||||
t.Errorf("unexpected fake-reply issue found: %s", issue.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for testing
|
// Helper functions for testing
|
||||||
func strPtr(s string) *string {
|
func strPtr(s string) *string {
|
||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolPtr(b bool) *bool {
|
|
||||||
return &b
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptrToStr(p *string) string {
|
func ptrToStr(p *string) string {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return "<nil>"
|
return "<nil>"
|
||||||
|
|
@ -1227,16 +1012,16 @@ func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create authentication results with DKIM signatures
|
// Create authentication results with DKIM signatures
|
||||||
var authResults *model.AuthenticationResults
|
var authResults *api.AuthenticationResults
|
||||||
if len(tt.dkimDomains) > 0 {
|
if len(tt.dkimDomains) > 0 {
|
||||||
dkimResults := make([]model.AuthResult, 0, len(tt.dkimDomains))
|
dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains))
|
||||||
for _, domain := range tt.dkimDomains {
|
for _, domain := range tt.dkimDomains {
|
||||||
dkimResults = append(dkimResults, model.AuthResult{
|
dkimResults = append(dkimResults, api.AuthResult{
|
||||||
Result: model.AuthResultResultPass,
|
Result: api.AuthResultResultPass,
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
authResults = &model.AuthenticationResults{
|
authResults = &api.AuthenticationResults{
|
||||||
Dkim: &dkimResults,
|
Dkim: &dkimResults,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,16 @@ import (
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var hostname = ""
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
hostname, _ = os.Hostname()
|
||||||
|
}
|
||||||
|
|
||||||
// EmailMessage represents a parsed email message
|
// EmailMessage represents a parsed email message
|
||||||
type EmailMessage struct {
|
type EmailMessage struct {
|
||||||
Header mail.Header
|
Header mail.Header
|
||||||
|
|
@ -211,18 +218,18 @@ func buildRawHeaders(header mail.Header) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAuthenticationResults extracts Authentication-Results headers
|
// GetAuthenticationResults extracts Authentication-Results headers
|
||||||
// If receiverHostname is provided, only returns headers that begin with that hostname
|
// If hostname is provided, only returns headers that begin with that hostname
|
||||||
func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string {
|
func (e *EmailMessage) GetAuthenticationResults() []string {
|
||||||
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
||||||
|
|
||||||
// If no hostname specified, return all results
|
// If no hostname specified, return all results
|
||||||
if receiverHostname == "" {
|
if hostname == "" {
|
||||||
return allResults
|
return allResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter results that begin with the specified hostname
|
// Filter results that begin with the specified hostname
|
||||||
var filtered []string
|
var filtered []string
|
||||||
prefix := receiverHostname + ";"
|
prefix := hostname + ";"
|
||||||
for _, result := range allResults {
|
for _, result := range allResults {
|
||||||
// Trim whitespace and check if it starts with hostname;
|
// Trim whitespace and check if it starts with hostname;
|
||||||
trimmed := strings.TrimSpace(result)
|
trimmed := strings.TrimSpace(result)
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,9 @@ Content-Type: text/html; charset=utf-8
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetAuthenticationResults(t *testing.T) {
|
func TestGetAuthenticationResults(t *testing.T) {
|
||||||
|
// Force hostname
|
||||||
|
hostname = "example.com"
|
||||||
|
|
||||||
rawEmail := `From: sender@example.com
|
rawEmail := `From: sender@example.com
|
||||||
To: recipient@example.com
|
To: recipient@example.com
|
||||||
Subject: Test Email
|
Subject: Test Email
|
||||||
|
|
@ -120,7 +123,7 @@ Body content.
|
||||||
t.Fatalf("Failed to parse email: %v", err)
|
t.Fatalf("Failed to parse email: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authResults := email.GetAuthenticationResults("example.com")
|
authResults := email.GetAuthenticationResults()
|
||||||
if len(authResults) != 2 {
|
if len(authResults) != 2 {
|
||||||
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
|
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,9 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
|
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
|
||||||
|
|
@ -55,6 +53,8 @@ var DefaultRBLs = []string{
|
||||||
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
|
||||||
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
|
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
|
||||||
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
|
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
|
||||||
|
"spam.spamrats.com", // SpamRats SPAM
|
||||||
|
"dyna.spamrats.com", // SpamRats dynamic IPs
|
||||||
"psbl.surriel.com", // PSBL
|
"psbl.surriel.com", // PSBL
|
||||||
"dnsbl.dronebl.org", // DroneBL
|
"dnsbl.dronebl.org", // DroneBL
|
||||||
"bl.mailspike.net", // Mailspike BL
|
"bl.mailspike.net", // Mailspike BL
|
||||||
|
|
@ -118,7 +118,7 @@ func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *
|
||||||
|
|
||||||
// DNSListResults represents the results of DNS list checks
|
// DNSListResults represents the results of DNS list checks
|
||||||
type DNSListResults struct {
|
type DNSListResults struct {
|
||||||
Checks map[string][]model.BlacklistCheck // Map of IP -> list of checks for that IP
|
Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP
|
||||||
IPsChecked []string
|
IPsChecked []string
|
||||||
ListedCount int // Total listings including informational entries
|
ListedCount int // Total listings including informational entries
|
||||||
RelevantListedCount int // Listings on scoring (non-informational) lists only
|
RelevantListedCount int // Listings on scoring (non-informational) lists only
|
||||||
|
|
@ -127,7 +127,7 @@ type DNSListResults struct {
|
||||||
// CheckEmail checks all IPs found in the email headers against the configured lists
|
// CheckEmail checks all IPs found in the email headers against the configured lists
|
||||||
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||||
results := &DNSListResults{
|
results := &DNSListResults{
|
||||||
Checks: make(map[string][]model.BlacklistCheck),
|
Checks: make(map[string][]api.BlacklistCheck),
|
||||||
}
|
}
|
||||||
|
|
||||||
ips := r.extractIPs(email)
|
ips := r.extractIPs(email)
|
||||||
|
|
@ -157,26 +157,18 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckIP checks a single IP address against all configured lists in parallel
|
// CheckIP checks a single IP address against all configured lists
|
||||||
func (r *DNSListChecker) CheckIP(ip string) ([]model.BlacklistCheck, int, error) {
|
func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
||||||
if !r.isPublicIP(ip) {
|
if !r.isPublicIP(ip) {
|
||||||
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
checks := make([]model.BlacklistCheck, len(r.Lists))
|
var checks []api.BlacklistCheck
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
for i, list := range r.Lists {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int, list string) {
|
|
||||||
defer wg.Done()
|
|
||||||
checks[i] = r.checkIP(ip, list)
|
|
||||||
}(i, list)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
listedCount := 0
|
listedCount := 0
|
||||||
for _, check := range checks {
|
|
||||||
|
for _, list := range r.Lists {
|
||||||
|
check := r.checkIP(ip, list)
|
||||||
|
checks = append(checks, check)
|
||||||
if check.Listed {
|
if check.Listed {
|
||||||
listedCount++
|
listedCount++
|
||||||
}
|
}
|
||||||
|
|
@ -240,14 +232,14 @@ func (r *DNSListChecker) isPublicIP(ipStr string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkIP checks a single IP against a single DNS list
|
// checkIP checks a single IP against a single DNS list
|
||||||
func (r *DNSListChecker) checkIP(ip, list string) model.BlacklistCheck {
|
func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
|
||||||
check := model.BlacklistCheck{
|
check := api.BlacklistCheck{
|
||||||
Rbl: list,
|
Rbl: list,
|
||||||
}
|
}
|
||||||
|
|
||||||
reversedIP := r.reverseIP(ip)
|
reversedIP := r.reverseIP(ip)
|
||||||
if reversedIP == "" {
|
if reversedIP == "" {
|
||||||
check.Error = utils.PtrTo("Failed to reverse IP address")
|
check.Error = api.PtrTo("Failed to reverse IP address")
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,17 +256,17 @@ func (r *DNSListChecker) checkIP(ip, list string) model.BlacklistCheck {
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
check.Error = utils.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(addrs) > 0 {
|
if len(addrs) > 0 {
|
||||||
check.Response = utils.PtrTo(addrs[0])
|
check.Response = api.PtrTo(addrs[0])
|
||||||
|
|
||||||
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
|
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
|
||||||
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
|
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
|
||||||
check.Listed = false
|
check.Listed = false
|
||||||
check.Error = utils.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
|
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
|
||||||
} else {
|
} else {
|
||||||
check.Listed = true
|
check.Listed = true
|
||||||
}
|
}
|
||||||
|
|
@ -301,24 +293,13 @@ func (r *DNSListChecker) reverseIP(ipStr string) string {
|
||||||
|
|
||||||
// CalculateScore calculates the list contribution to deliverability.
|
// CalculateScore calculates the list contribution to deliverability.
|
||||||
// Informational lists are not counted in the score.
|
// Informational lists are not counted in the score.
|
||||||
func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bool) (int, string) {
|
func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) {
|
||||||
scoringListCount := len(r.Lists) - len(r.informationalSet)
|
|
||||||
|
|
||||||
if forWhitelist {
|
|
||||||
if results.ListedCount >= scoringListCount {
|
|
||||||
return 100, "A++"
|
|
||||||
} else if results.ListedCount > 0 {
|
|
||||||
return 100, "A+"
|
|
||||||
} else {
|
|
||||||
return 95, "A"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if results == nil || len(results.IPsChecked) == 0 {
|
if results == nil || len(results.IPsChecked) == 0 {
|
||||||
return 100, ""
|
return 100, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if results.ListedCount <= 0 || scoringListCount <= 0 {
|
scoringListCount := len(r.Lists) - len(r.informationalSet)
|
||||||
|
if scoringListCount <= 0 {
|
||||||
return 100, "A+"
|
return 100, "A+"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewRBLChecker(t *testing.T) {
|
func TestNewRBLChecker(t *testing.T) {
|
||||||
|
|
@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) {
|
||||||
func TestGetBlacklistScore(t *testing.T) {
|
func TestGetBlacklistScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
results *DNSListResults
|
results *RBLResults
|
||||||
expectedScore int
|
expectedScore int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
|
@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No IPs checked",
|
name: "No IPs checked",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{},
|
IPsChecked: []string{},
|
||||||
},
|
},
|
||||||
expectedScore: 100,
|
expectedScore: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Not listed on any RBL",
|
name: "Not listed on any RBL",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 0,
|
ListedCount: 0,
|
||||||
},
|
},
|
||||||
|
|
@ -290,39 +290,35 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 1 RBL",
|
name: "Listed on 1 RBL",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 1,
|
ListedCount: 1,
|
||||||
RelevantListedCount: 1,
|
|
||||||
},
|
},
|
||||||
expectedScore: 92, // 100 - 1*100/12 = 92 (12 scoring lists = 14 default - 2 informational)
|
expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 2 RBLs",
|
name: "Listed on 2 RBLs",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 2,
|
ListedCount: 2,
|
||||||
RelevantListedCount: 2,
|
|
||||||
},
|
},
|
||||||
expectedScore: 84, // 100 - 2*100/12 = 84
|
expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 3 RBLs",
|
name: "Listed on 3 RBLs",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 3,
|
ListedCount: 3,
|
||||||
RelevantListedCount: 3,
|
|
||||||
},
|
},
|
||||||
expectedScore: 75, // 100 - 3*100/12 = 75
|
expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Listed on 4+ RBLs",
|
name: "Listed on 4+ RBLs",
|
||||||
results: &DNSListResults{
|
results: &RBLResults{
|
||||||
IPsChecked: []string{"198.51.100.1"},
|
IPsChecked: []string{"198.51.100.1"},
|
||||||
ListedCount: 4,
|
ListedCount: 4,
|
||||||
RelevantListedCount: 4,
|
|
||||||
},
|
},
|
||||||
expectedScore: 67, // 100 - 4*100/12 = 67
|
expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
score, _ := checker.CalculateScore(tt.results, false)
|
score, _ := checker.CalculateScore(tt.results)
|
||||||
if score != tt.expectedScore {
|
if score != tt.expectedScore {
|
||||||
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
||||||
}
|
}
|
||||||
|
|
@ -339,8 +335,8 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetUniqueListedIPs(t *testing.T) {
|
func TestGetUniqueListedIPs(t *testing.T) {
|
||||||
results := &DNSListResults{
|
results := &RBLResults{
|
||||||
Checks: map[string][]model.BlacklistCheck{
|
Checks: map[string][]api.BlacklistCheck{
|
||||||
"198.51.100.1": {
|
"198.51.100.1": {
|
||||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||||
{Rbl: "bl.spamcop.net", Listed: true},
|
{Rbl: "bl.spamcop.net", Listed: true},
|
||||||
|
|
@ -367,8 +363,8 @@ func TestGetUniqueListedIPs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetRBLsForIP(t *testing.T) {
|
func TestGetRBLsForIP(t *testing.T) {
|
||||||
results := &DNSListResults{
|
results := &RBLResults{
|
||||||
Checks: map[string][]model.BlacklistCheck{
|
Checks: map[string][]api.BlacklistCheck{
|
||||||
"198.51.100.1": {
|
"198.51.100.1": {
|
||||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||||
{Rbl: "bl.spamcop.net", Listed: true},
|
{Rbl: "bl.spamcop.net", Listed: true},
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
@ -43,18 +43,16 @@ type ReportGenerator struct {
|
||||||
|
|
||||||
// NewReportGenerator creates a new report generator
|
// NewReportGenerator creates a new report generator
|
||||||
func NewReportGenerator(
|
func NewReportGenerator(
|
||||||
receiverHostname string,
|
|
||||||
dnsTimeout time.Duration,
|
dnsTimeout time.Duration,
|
||||||
httpTimeout time.Duration,
|
httpTimeout time.Duration,
|
||||||
rbls []string,
|
rbls []string,
|
||||||
dnswls []string,
|
dnswls []string,
|
||||||
checkAllIPs bool,
|
checkAllIPs bool,
|
||||||
rspamdAPIURL string,
|
|
||||||
) *ReportGenerator {
|
) *ReportGenerator {
|
||||||
return &ReportGenerator{
|
return &ReportGenerator{
|
||||||
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
|
authAnalyzer: NewAuthenticationAnalyzer(),
|
||||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||||
rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
|
rspamdAnalyzer: NewRspamdAnalyzer(),
|
||||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||||
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
|
||||||
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
|
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
|
||||||
|
|
@ -66,14 +64,14 @@ func NewReportGenerator(
|
||||||
// AnalysisResults contains all intermediate analysis results
|
// AnalysisResults contains all intermediate analysis results
|
||||||
type AnalysisResults struct {
|
type AnalysisResults struct {
|
||||||
Email *EmailMessage
|
Email *EmailMessage
|
||||||
Authentication *model.AuthenticationResults
|
Authentication *api.AuthenticationResults
|
||||||
Content *ContentResults
|
Content *ContentResults
|
||||||
DNS *model.DNSResults
|
DNS *api.DNSResults
|
||||||
Headers *model.HeaderAnalysis
|
Headers *api.HeaderAnalysis
|
||||||
RBL *DNSListResults
|
RBL *DNSListResults
|
||||||
DNSWL *DNSListResults
|
DNSWL *DNSListResults
|
||||||
SpamAssassin *model.SpamAssassinResult
|
SpamAssassin *api.SpamAssassinResult
|
||||||
Rspamd *model.RspamdResult
|
Rspamd *api.RspamdResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeEmail performs complete email analysis
|
// AnalyzeEmail performs complete email analysis
|
||||||
|
|
@ -85,11 +83,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
||||||
// Run all analyzers
|
// Run all analyzers
|
||||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
||||||
// Fall back to the received chain's inbound TLS when no x-tls header was present.
|
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
|
||||||
if results.Authentication != nil && results.Headers != nil {
|
|
||||||
r.authAnalyzer.ReconcileXTLS(results.Authentication, results.Headers.ReceivedChain)
|
|
||||||
}
|
|
||||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
|
|
||||||
results.RBL = r.rblChecker.CheckEmail(email)
|
results.RBL = r.rblChecker.CheckEmail(email)
|
||||||
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
||||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||||
|
|
@ -100,11 +94,11 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateReport creates a complete API report from analysis results
|
// GenerateReport creates a complete API report from analysis results
|
||||||
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *model.Report {
|
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report {
|
||||||
reportID := uuid.New()
|
reportID := uuid.New()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
report := &model.Report{
|
report := &api.Report{
|
||||||
Id: utils.UUIDToBase32(reportID),
|
Id: utils.UUIDToBase32(reportID),
|
||||||
TestId: utils.UUIDToBase32(testID),
|
TestId: utils.UUIDToBase32(testID),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
|
|
@ -145,10 +139,8 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
|
|
||||||
blacklistScore := 0
|
blacklistScore := 0
|
||||||
var blacklistGrade string
|
var blacklistGrade string
|
||||||
var whitelistGrade string
|
|
||||||
if results.RBL != nil {
|
if results.RBL != nil {
|
||||||
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false)
|
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL)
|
||||||
_, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
|
||||||
|
|
@ -173,19 +165,19 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
spamGrade = MinGrade(saGrade, rspamdGrade)
|
spamGrade = MinGrade(saGrade, rspamdGrade)
|
||||||
}
|
}
|
||||||
|
|
||||||
report.Summary = &model.ScoreSummary{
|
report.Summary = &api.ScoreSummary{
|
||||||
DnsScore: dnsScore,
|
DnsScore: dnsScore,
|
||||||
DnsGrade: model.ScoreSummaryDnsGrade(dnsGrade),
|
DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade),
|
||||||
AuthenticationScore: authScore,
|
AuthenticationScore: authScore,
|
||||||
AuthenticationGrade: model.ScoreSummaryAuthenticationGrade(authGrade),
|
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
|
||||||
BlacklistScore: blacklistScore,
|
BlacklistScore: blacklistScore,
|
||||||
BlacklistGrade: model.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
|
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
|
||||||
ContentScore: contentScore,
|
ContentScore: contentScore,
|
||||||
ContentGrade: model.ScoreSummaryContentGrade(contentGrade),
|
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
|
||||||
HeaderScore: headerScore,
|
HeaderScore: headerScore,
|
||||||
HeaderGrade: model.ScoreSummaryHeaderGrade(headerGrade),
|
HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade),
|
||||||
SpamScore: spamScore,
|
SpamScore: spamScore,
|
||||||
SpamGrade: model.ScoreSummarySpamGrade(spamGrade),
|
SpamGrade: api.ScoreSummarySpamGrade(spamGrade),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add authentication results
|
// Add authentication results
|
||||||
|
|
@ -217,16 +209,16 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
|
|
||||||
// Add SpamAssassin result with individual deliverability score
|
// Add SpamAssassin result with individual deliverability score
|
||||||
if results.SpamAssassin != nil {
|
if results.SpamAssassin != nil {
|
||||||
saGradeTyped := model.SpamAssassinResultDeliverabilityGrade(saGrade)
|
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)
|
||||||
results.SpamAssassin.DeliverabilityScore = utils.PtrTo(saScore)
|
results.SpamAssassin.DeliverabilityScore = api.PtrTo(saScore)
|
||||||
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
|
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
|
||||||
}
|
}
|
||||||
report.Spamassassin = results.SpamAssassin
|
report.Spamassassin = results.SpamAssassin
|
||||||
|
|
||||||
// Add rspamd result with individual deliverability score
|
// Add rspamd result with individual deliverability score
|
||||||
if results.Rspamd != nil {
|
if results.Rspamd != nil {
|
||||||
rspamdGradeTyped := model.RspamdResultDeliverabilityGrade(rspamdGrade)
|
rspamdGradeTyped := api.RspamdResultDeliverabilityGrade(rspamdGrade)
|
||||||
results.Rspamd.DeliverabilityScore = utils.PtrTo(rspamdScore)
|
results.Rspamd.DeliverabilityScore = api.PtrTo(rspamdScore)
|
||||||
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
|
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
|
||||||
}
|
}
|
||||||
report.Rspamd = results.Rspamd
|
report.Rspamd = results.Rspamd
|
||||||
|
|
@ -292,7 +284,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
}
|
}
|
||||||
|
|
||||||
if minusGrade < 255 {
|
if minusGrade < 255 {
|
||||||
report.Grade = model.ReportGrade(string([]byte{'A' + minusGrade}))
|
report.Grade = api.ReportGrade(string([]byte{'A' + minusGrade}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewReportGenerator(t *testing.T) {
|
func TestNewReportGenerator(t *testing.T) {
|
||||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||||
if gen == nil {
|
if gen == nil {
|
||||||
t.Fatal("Expected report generator, got nil")
|
t.Fatal("Expected report generator, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnalyzeEmail(t *testing.T) {
|
func TestAnalyzeEmail(t *testing.T) {
|
||||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||||
|
|
||||||
email := createTestEmail()
|
email := createTestEmail()
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateReport(t *testing.T) {
|
func TestGenerateReport(t *testing.T) {
|
||||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||||
testID := uuid.New()
|
testID := uuid.New()
|
||||||
|
|
||||||
email := createTestEmail()
|
email := createTestEmail()
|
||||||
|
|
@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||||
testID := uuid.New()
|
testID := uuid.New()
|
||||||
|
|
||||||
email := createTestEmailWithSpamAssassin()
|
email := createTestEmailWithSpamAssassin()
|
||||||
|
|
@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateRawEmail(t *testing.T) {
|
func TestGenerateRawEmail(t *testing.T) {
|
||||||
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false, "")
|
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# rspamd-symbols.json
|
|
||||||
|
|
||||||
This file contains rspamd symbol descriptions, embedded into the binary at compile time as a fallback when no rspamd API URL is configured.
|
|
||||||
|
|
||||||
## How to update
|
|
||||||
|
|
||||||
Fetch the latest symbols from a running rspamd instance:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Or with docker:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker run --rm --name rspamd --pull always rspamd/rspamd
|
|
||||||
docker exec -u 0 rspamd apt install -y curl
|
|
||||||
docker exec rspamd curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Then rebuild the project.
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -27,7 +27,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default rspamd action thresholds (rspamd built-in defaults)
|
// Default rspamd action thresholds (rspamd built-in defaults)
|
||||||
|
|
@ -37,38 +37,27 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// RspamdAnalyzer analyzes rspamd results from email headers
|
// RspamdAnalyzer analyzes rspamd results from email headers
|
||||||
type RspamdAnalyzer struct {
|
type RspamdAnalyzer struct{}
|
||||||
symbols map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions
|
// NewRspamdAnalyzer creates a new rspamd analyzer
|
||||||
func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer {
|
func NewRspamdAnalyzer() *RspamdAnalyzer {
|
||||||
return &RspamdAnalyzer{symbols: symbols}
|
return &RspamdAnalyzer{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
||||||
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult {
|
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||||
headers := email.GetRspamdHeaders()
|
headers := email.GetRspamdHeaders()
|
||||||
if len(headers) == 0 {
|
if len(headers) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report
|
result := &api.RspamdResult{
|
||||||
_, hasSpamdResult := headers["X-Spamd-Result"]
|
Symbols: make(map[string]api.RspamdSymbol),
|
||||||
_, hasRspamdScore := headers["X-Rspamd-Score"]
|
|
||||||
if !hasSpamdResult && !hasRspamdScore {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &model.RspamdResult{
|
|
||||||
Symbols: make(map[string]model.SpamTestDetail),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
||||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||||
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
|
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
|
||||||
report := strings.ReplaceAll(spamdResult, "; ", ";\n")
|
|
||||||
result.Report = &report
|
|
||||||
a.parseSpamdResult(spamdResult, result)
|
a.parseSpamdResult(spamdResult, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,16 +74,6 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult
|
||||||
result.Server = &server
|
result.Server = &server
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate symbol descriptions from the lookup map
|
|
||||||
if a.symbols != nil {
|
|
||||||
for name, sym := range result.Symbols {
|
|
||||||
if desc, ok := a.symbols[name]; ok {
|
|
||||||
sym.Description = &desc
|
|
||||||
result.Symbols[name] = sym
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive IsSpam from score vs reject threshold.
|
// Derive IsSpam from score vs reject threshold.
|
||||||
if result.Threshold > 0 {
|
if result.Threshold > 0 {
|
||||||
result.IsSpam = result.Score >= result.Threshold
|
result.IsSpam = result.Score >= result.Threshold
|
||||||
|
|
@ -107,7 +86,7 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult
|
||||||
|
|
||||||
// parseSpamdResult parses the X-Spamd-Result header
|
// parseSpamdResult parses the X-Spamd-Result header
|
||||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||||
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdResult) {
|
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResult) {
|
||||||
// Extract score and threshold from the first line
|
// Extract score and threshold from the first line
|
||||||
// e.g. "default: False [-3.91 / 15.00]"
|
// e.g. "default: False [-3.91 / 15.00]"
|
||||||
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
|
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
|
||||||
|
|
@ -132,16 +111,15 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdRes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse symbols: SYMBOL(score)[params]
|
// Parse symbols: SYMBOL(score)[params]
|
||||||
// Each symbol entry is separated by ";", so within each part we use a
|
// Each symbol entry is separated by ";"
|
||||||
// greedy match to capture params that may contain nested brackets.
|
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[([^\]]*)\])?`)
|
||||||
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`)
|
|
||||||
for _, part := range strings.Split(header, ";") {
|
for _, part := range strings.Split(header, ";") {
|
||||||
part = strings.TrimSpace(part)
|
part = strings.TrimSpace(part)
|
||||||
matches := symbolRe.FindStringSubmatch(part)
|
matches := symbolRe.FindStringSubmatch(part)
|
||||||
if len(matches) > 2 {
|
if len(matches) > 2 {
|
||||||
name := matches[1]
|
name := matches[1]
|
||||||
score, _ := strconv.ParseFloat(matches[2], 64)
|
score, _ := strconv.ParseFloat(matches[2], 64)
|
||||||
sym := model.SpamTestDetail{
|
sym := api.RspamdSymbol{
|
||||||
Name: name,
|
Name: name,
|
||||||
Score: float32(score),
|
Score: float32(score),
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +133,7 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdRes
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
|
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
|
||||||
func (a *RspamdAnalyzer) CalculateRspamdScore(result *model.RspamdResult) (int, string) {
|
func (a *RspamdAnalyzer) CalculateRspamdScore(result *api.RspamdResult) (int, string) {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return 100, "" // rspamd not installed
|
return 100, "" // rspamd not installed
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2026 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed rspamd-symbols.json
|
|
||||||
var embeddedRspamdSymbols []byte
|
|
||||||
|
|
||||||
// rspamdSymbolGroup represents a group of rspamd symbols from the API/embedded JSON.
|
|
||||||
type rspamdSymbolGroup struct {
|
|
||||||
Group string `json:"group"`
|
|
||||||
Rules []rspamdSymbolEntry `json:"rules"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// rspamdSymbolEntry represents a single rspamd symbol entry.
|
|
||||||
type rspamdSymbolEntry struct {
|
|
||||||
Symbol string `json:"symbol"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Weight float64 `json:"weight"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseRspamdSymbolsJSON parses the rspamd symbols JSON into a name->description map.
|
|
||||||
func parseRspamdSymbolsJSON(data []byte) map[string]string {
|
|
||||||
var groups []rspamdSymbolGroup
|
|
||||||
if err := json.Unmarshal(data, &groups); err != nil {
|
|
||||||
log.Printf("Failed to parse rspamd symbols JSON: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
symbols := make(map[string]string, len(groups)*10)
|
|
||||||
for _, g := range groups {
|
|
||||||
for _, r := range g.Rules {
|
|
||||||
if r.Description != "" {
|
|
||||||
symbols[r.Symbol] = r.Description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return symbols
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadRspamdSymbols loads rspamd symbol descriptions.
|
|
||||||
// If apiURL is non-empty, it fetches from the rspamd API first, falling back to the embedded list on error.
|
|
||||||
func LoadRspamdSymbols(apiURL string) map[string]string {
|
|
||||||
if apiURL != "" {
|
|
||||||
if symbols := fetchRspamdSymbols(apiURL); symbols != nil {
|
|
||||||
return symbols
|
|
||||||
}
|
|
||||||
log.Printf("Failed to fetch rspamd symbols from %s, using embedded list", apiURL)
|
|
||||||
}
|
|
||||||
return parseRspamdSymbolsJSON(embeddedRspamdSymbols)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchRspamdSymbols fetches symbol descriptions from the rspamd API.
|
|
||||||
func fetchRspamdSymbols(apiURL string) map[string]string {
|
|
||||||
url := strings.TrimRight(apiURL, "/") + "/symbols"
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error fetching rspamd symbols: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
log.Printf("rspamd API returned status %d", resp.StatusCode)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error reading rspamd symbols response: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseRspamdSymbolsJSON(body)
|
|
||||||
}
|
|
||||||
|
|
@ -1,414 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2026 happyDomain
|
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
|
||||||
//
|
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
|
||||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
||||||
//
|
|
||||||
// For AGPL licensing:
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"net/mail"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
|
|
||||||
analyzer := NewRspamdAnalyzer(nil)
|
|
||||||
email := &EmailMessage{Header: make(mail.Header)}
|
|
||||||
|
|
||||||
result := analyzer.AnalyzeRspamd(email)
|
|
||||||
|
|
||||||
if result != nil {
|
|
||||||
t.Errorf("Expected nil for email without rspamd headers, got %+v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSpamdResult(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
header string
|
|
||||||
expectedScore float32
|
|
||||||
expectedThreshold float32
|
|
||||||
expectedIsSpam bool
|
|
||||||
expectedSymbols map[string]float32
|
|
||||||
expectedSymParams map[string]string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Clean email negative score",
|
|
||||||
header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]",
|
|
||||||
expectedScore: -3.91,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
expectedSymbols: map[string]float32{
|
|
||||||
"DATE_IN_PAST": 0.10,
|
|
||||||
"ALL_TRUSTED": -1.00,
|
|
||||||
},
|
|
||||||
expectedSymParams: map[string]string{
|
|
||||||
"ALL_TRUSTED": "trusted",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Spam email True flag",
|
|
||||||
header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)",
|
|
||||||
expectedScore: 16.50,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: true,
|
|
||||||
expectedSymbols: map[string]float32{
|
|
||||||
"BAYES_99": 5.00,
|
|
||||||
"SPOOFED_SENDER": 3.50,
|
|
||||||
},
|
|
||||||
expectedSymParams: map[string]string{
|
|
||||||
"BAYES_99": "1.00",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Zero threshold uses default",
|
|
||||||
header: "default: False [1.00 / 0.00]",
|
|
||||||
expectedScore: 1.00,
|
|
||||||
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
expectedSymbols: map[string]float32{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Symbol without params",
|
|
||||||
header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)",
|
|
||||||
expectedScore: 2.00,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
expectedSymbols: map[string]float32{
|
|
||||||
"MISSING_DATE": 1.00,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Case-insensitive true flag",
|
|
||||||
header: "default: true [8.00 / 6.00]",
|
|
||||||
expectedScore: 8.00,
|
|
||||||
expectedThreshold: 6.00,
|
|
||||||
expectedIsSpam: true,
|
|
||||||
expectedSymbols: map[string]float32{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Zero threshold with symbols containing nested brackets in params",
|
|
||||||
header: "default: False [0.90 / 0.00];\n" +
|
|
||||||
"\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" +
|
|
||||||
"\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" +
|
|
||||||
"\tMIME_TRACE(0.00)[0:+,1:+,2:~]",
|
|
||||||
expectedScore: 0.90,
|
|
||||||
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
expectedSymbols: map[string]float32{
|
|
||||||
"ARC_REJECT": 1.00,
|
|
||||||
"MIME_GOOD": -0.10,
|
|
||||||
"MIME_TRACE": 0.00,
|
|
||||||
},
|
|
||||||
expectedSymParams: map[string]string{
|
|
||||||
"ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}",
|
|
||||||
"MIME_GOOD": "multipart/alternative,text/plain",
|
|
||||||
"MIME_TRACE": "0:+,1:+,2:~",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewRspamdAnalyzer(nil)
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := &model.RspamdResult{
|
|
||||||
Symbols: make(map[string]model.SpamTestDetail),
|
|
||||||
}
|
|
||||||
analyzer.parseSpamdResult(tt.header, result)
|
|
||||||
|
|
||||||
if result.Score != tt.expectedScore {
|
|
||||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
|
||||||
}
|
|
||||||
if result.Threshold != tt.expectedThreshold {
|
|
||||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
|
||||||
}
|
|
||||||
if result.IsSpam != tt.expectedIsSpam {
|
|
||||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
|
||||||
}
|
|
||||||
for symName, expectedScore := range tt.expectedSymbols {
|
|
||||||
sym, ok := result.Symbols[symName]
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("Symbol %s not found", symName)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if sym.Score != expectedScore {
|
|
||||||
t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for symName, expectedParam := range tt.expectedSymParams {
|
|
||||||
sym, ok := result.Symbols[symName]
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("Symbol %s not found for params check", symName)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if sym.Params == nil {
|
|
||||||
t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam)
|
|
||||||
} else if *sym.Params != expectedParam {
|
|
||||||
t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnalyzeRspamd(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
headers map[string]string
|
|
||||||
expectedScore float32
|
|
||||||
expectedThreshold float32
|
|
||||||
expectedIsSpam bool
|
|
||||||
expectedServer *string
|
|
||||||
expectedSymCount int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Full headers clean email",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]",
|
|
||||||
"X-Rspamd-Score": "-3.91",
|
|
||||||
"X-Rspamd-Server": "mail.example.com",
|
|
||||||
},
|
|
||||||
expectedScore: -3.91,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
expectedServer: func() *string { s := "mail.example.com"; return &s }(),
|
|
||||||
expectedSymCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "X-Rspamd-Score overrides spamd result score",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Spamd-Result": "default: False [2.00 / 15.00]",
|
|
||||||
"X-Rspamd-Score": "3.50",
|
|
||||||
},
|
|
||||||
expectedScore: 3.50,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Spam email above threshold",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)",
|
|
||||||
"X-Rspamd-Score": "16.00",
|
|
||||||
},
|
|
||||||
expectedScore: 16.00,
|
|
||||||
expectedThreshold: 15.00,
|
|
||||||
expectedIsSpam: true,
|
|
||||||
expectedSymCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Rspamd-Score": "2.00",
|
|
||||||
},
|
|
||||||
expectedScore: 2.00,
|
|
||||||
expectedIsSpam: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Rspamd-Score": "7.00",
|
|
||||||
},
|
|
||||||
expectedScore: 7.00,
|
|
||||||
expectedIsSpam: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Server header is trimmed",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Rspamd-Score": "1.00",
|
|
||||||
"X-Rspamd-Server": " rspamd-01 ",
|
|
||||||
},
|
|
||||||
expectedScore: 1.00,
|
|
||||||
expectedServer: func() *string { s := "rspamd-01"; return &s }(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewRspamdAnalyzer(nil)
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
email := &EmailMessage{Header: make(mail.Header)}
|
|
||||||
for k, v := range tt.headers {
|
|
||||||
email.Header[k] = []string{v}
|
|
||||||
}
|
|
||||||
|
|
||||||
result := analyzer.AnalyzeRspamd(email)
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("Expected non-nil result")
|
|
||||||
}
|
|
||||||
if result.Score != tt.expectedScore {
|
|
||||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
|
||||||
}
|
|
||||||
if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
|
|
||||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
|
||||||
}
|
|
||||||
if result.IsSpam != tt.expectedIsSpam {
|
|
||||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
|
||||||
}
|
|
||||||
if tt.expectedServer != nil {
|
|
||||||
if result.Server == nil {
|
|
||||||
t.Errorf("Server = nil, want %q", *tt.expectedServer)
|
|
||||||
} else if *result.Server != *tt.expectedServer {
|
|
||||||
t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
|
|
||||||
t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculateRspamdScore(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
result *model.RspamdResult
|
|
||||||
expectedScore int
|
|
||||||
expectedGrade string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Nil result (rspamd not installed)",
|
|
||||||
result: nil,
|
|
||||||
expectedScore: 100,
|
|
||||||
expectedGrade: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Score well below threshold",
|
|
||||||
result: &model.RspamdResult{
|
|
||||||
Score: -3.91,
|
|
||||||
Threshold: 15.00,
|
|
||||||
},
|
|
||||||
expectedScore: 100,
|
|
||||||
expectedGrade: "A+",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Score at zero",
|
|
||||||
result: &model.RspamdResult{
|
|
||||||
Score: 0,
|
|
||||||
Threshold: 15.00,
|
|
||||||
},
|
|
||||||
// 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
|
|
||||||
expectedScore: 100,
|
|
||||||
expectedGrade: "A",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Score at threshold (half of 2*threshold)",
|
|
||||||
result: &model.RspamdResult{
|
|
||||||
Score: 15.00,
|
|
||||||
Threshold: 15.00,
|
|
||||||
},
|
|
||||||
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
|
|
||||||
expectedScore: 50,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Score above 2*threshold",
|
|
||||||
result: &model.RspamdResult{
|
|
||||||
Score: 31.00,
|
|
||||||
Threshold: 15.00,
|
|
||||||
},
|
|
||||||
expectedScore: 0,
|
|
||||||
expectedGrade: "F",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Score exactly at 2*threshold",
|
|
||||||
result: &model.RspamdResult{
|
|
||||||
Score: 30.00,
|
|
||||||
Threshold: 15.00,
|
|
||||||
},
|
|
||||||
// 100 - round(30*100/30) = 100 - 100 = 0
|
|
||||||
expectedScore: 0,
|
|
||||||
expectedGrade: "F",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewRspamdAnalyzer(nil)
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
score, grade := analyzer.CalculateRspamdScore(tt.result)
|
|
||||||
|
|
||||||
if score != tt.expectedScore {
|
|
||||||
t.Errorf("Score = %d, want %d", score, tt.expectedScore)
|
|
||||||
}
|
|
||||||
if tt.expectedGrade != "" && grade != tt.expectedGrade {
|
|
||||||
t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00];
|
|
||||||
BAYES_HAM(-3.00)[99%];
|
|
||||||
RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from];
|
|
||||||
R_DKIM_ALLOW(-0.20)[example.com:s=dkim];
|
|
||||||
FROM_HAS_DN(0.00)[];
|
|
||||||
MIME_GOOD(-0.10)[text/plain];
|
|
||||||
X-Rspamd-Score: -3.91
|
|
||||||
X-Rspamd-Server: rspamd-01.example.com
|
|
||||||
Date: Mon, 09 Mar 2026 10:00:00 +0000
|
|
||||||
From: sender@example.com
|
|
||||||
To: test@happydomain.org
|
|
||||||
Subject: Test email
|
|
||||||
Message-ID: <test123@example.com>
|
|
||||||
MIME-Version: 1.0
|
|
||||||
Content-Type: text/plain
|
|
||||||
|
|
||||||
Hello world`
|
|
||||||
|
|
||||||
func TestAnalyzeRspamdRealEmail(t *testing.T) {
|
|
||||||
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse email: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewRspamdAnalyzer(nil)
|
|
||||||
result := analyzer.AnalyzeRspamd(email)
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("Expected non-nil result")
|
|
||||||
}
|
|
||||||
if result.IsSpam {
|
|
||||||
t.Error("Expected IsSpam=false")
|
|
||||||
}
|
|
||||||
if result.Score != -3.91 {
|
|
||||||
t.Errorf("Score = %v, want -3.91", result.Score)
|
|
||||||
}
|
|
||||||
if result.Threshold != 15.00 {
|
|
||||||
t.Errorf("Threshold = %v, want 15.00", result.Threshold)
|
|
||||||
}
|
|
||||||
if result.Server == nil || *result.Server != "rspamd-01.example.com" {
|
|
||||||
t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"}
|
|
||||||
for _, sym := range expectedSymbols {
|
|
||||||
if _, ok := result.Symbols[sym]; !ok {
|
|
||||||
t.Errorf("Symbol %s not found", sym)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
score, _ := analyzer.CalculateRspamdScore(result)
|
|
||||||
if score != 100 {
|
|
||||||
t.Errorf("CalculateRspamdScore = %d, want 100", score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
package analyzer
|
package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ScoreToGrade converts a percentage score (0-100) to a letter grade
|
// ScoreToGrade converts a percentage score (0-100) to a letter grade
|
||||||
|
|
@ -65,16 +65,14 @@ func ScoreToGradeKind(score int) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScoreToReportGrade converts a percentage score to an model.ReportGrade
|
// ScoreToReportGrade converts a percentage score to an api.ReportGrade
|
||||||
func ScoreToReportGrade(score int) model.ReportGrade {
|
func ScoreToReportGrade(score int) api.ReportGrade {
|
||||||
return model.ReportGrade(ScoreToGrade(score))
|
return api.ReportGrade(ScoreToGrade(score))
|
||||||
}
|
}
|
||||||
|
|
||||||
// gradeRank returns a numeric rank for a grade (lower = worse)
|
// gradeRank returns a numeric rank for a grade (lower = worse)
|
||||||
func gradeRank(grade string) int {
|
func gradeRank(grade string) int {
|
||||||
switch grade {
|
switch grade {
|
||||||
case "A++":
|
|
||||||
return 7
|
|
||||||
case "A+":
|
case "A+":
|
||||||
return 6
|
return 6
|
||||||
case "A":
|
case "A":
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
|
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
|
||||||
|
|
@ -40,22 +39,14 @@ func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
|
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
|
||||||
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.SpamAssassinResult {
|
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.SpamAssassinResult {
|
||||||
headers := email.GetSpamAssassinHeaders()
|
headers := email.GetSpamAssassinHeaders()
|
||||||
if len(headers) == 0 {
|
if len(headers) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report
|
result := &api.SpamAssassinResult{
|
||||||
_, hasStatus := headers["X-Spam-Status"]
|
TestDetails: make(map[string]api.SpamTestDetail),
|
||||||
_, hasScore := headers["X-Spam-Score"]
|
|
||||||
_, hasFlag := headers["X-Spam-Flag"]
|
|
||||||
if !hasStatus && !hasScore && !hasFlag {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &model.SpamAssassinResult{
|
|
||||||
TestDetails: make(map[string]model.SpamTestDetail),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse X-Spam-Status header
|
// Parse X-Spam-Status header
|
||||||
|
|
@ -77,13 +68,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.S
|
||||||
|
|
||||||
// Parse X-Spam-Report header for detailed test results
|
// Parse X-Spam-Report header for detailed test results
|
||||||
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
||||||
result.Report = utils.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
|
result.Report = api.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
|
||||||
a.parseSpamReport(reportHeader, result)
|
a.parseSpamReport(reportHeader, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse X-Spam-Checker-Version
|
// Parse X-Spam-Checker-Version
|
||||||
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
|
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
|
||||||
result.Version = utils.PtrTo(strings.TrimSpace(versionHeader))
|
result.Version = api.PtrTo(strings.TrimSpace(versionHeader))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -91,7 +82,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.S
|
||||||
|
|
||||||
// parseSpamStatus parses the X-Spam-Status header
|
// parseSpamStatus parses the X-Spam-Status header
|
||||||
// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no
|
// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no
|
||||||
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.SpamAssassinResult) {
|
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAssassinResult) {
|
||||||
// Check if spam (first word)
|
// Check if spam (first word)
|
||||||
parts := strings.SplitN(header, ",", 2)
|
parts := strings.SplitN(header, ",", 2)
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
|
|
@ -135,7 +126,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.Spam
|
||||||
// * 0.0 TEST_NAME Description line 1
|
// * 0.0 TEST_NAME Description line 1
|
||||||
// * continuation line 2
|
// * continuation line 2
|
||||||
// * continuation line 3
|
// * continuation line 3
|
||||||
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.SpamAssassinResult) {
|
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAssassinResult) {
|
||||||
segments := strings.Split(report, "*")
|
segments := strings.Split(report, "*")
|
||||||
|
|
||||||
// Regex to match test lines: score TEST_NAME Description
|
// Regex to match test lines: score TEST_NAME Description
|
||||||
|
|
@ -157,7 +148,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
|
||||||
// Save previous test if exists
|
// Save previous test if exists
|
||||||
if currentTestName != "" {
|
if currentTestName != "" {
|
||||||
description := strings.TrimSpace(currentDescription.String())
|
description := strings.TrimSpace(currentDescription.String())
|
||||||
detail := model.SpamTestDetail{
|
detail := api.SpamTestDetail{
|
||||||
Name: currentTestName,
|
Name: currentTestName,
|
||||||
Score: result.TestDetails[currentTestName].Score,
|
Score: result.TestDetails[currentTestName].Score,
|
||||||
Description: &description,
|
Description: &description,
|
||||||
|
|
@ -175,7 +166,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
|
||||||
currentDescription.WriteString(description)
|
currentDescription.WriteString(description)
|
||||||
|
|
||||||
// Initialize with score
|
// Initialize with score
|
||||||
result.TestDetails[testName] = model.SpamTestDetail{
|
result.TestDetails[testName] = api.SpamTestDetail{
|
||||||
Name: testName,
|
Name: testName,
|
||||||
Score: float32(score),
|
Score: float32(score),
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +183,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
|
||||||
// Save the last test if exists
|
// Save the last test if exists
|
||||||
if currentTestName != "" {
|
if currentTestName != "" {
|
||||||
description := strings.TrimSpace(currentDescription.String())
|
description := strings.TrimSpace(currentDescription.String())
|
||||||
detail := model.SpamTestDetail{
|
detail := api.SpamTestDetail{
|
||||||
Name: currentTestName,
|
Name: currentTestName,
|
||||||
Score: result.TestDetails[currentTestName].Score,
|
Score: result.TestDetails[currentTestName].Score,
|
||||||
Description: &description,
|
Description: &description,
|
||||||
|
|
@ -202,7 +193,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
|
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
|
||||||
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *model.SpamAssassinResult) (int, string) {
|
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) (int, string) {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return 100, "" // No spam scan results, assume good
|
return 100, "" // No spam scan results, assume good
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseSpamStatus(t *testing.T) {
|
func TestParseSpamStatus(t *testing.T) {
|
||||||
|
|
@ -78,8 +77,8 @@ func TestParseSpamStatus(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := &model.SpamAssassinResult{
|
result := &api.SpamAssassinResult{
|
||||||
TestDetails: make(map[string]model.SpamTestDetail),
|
TestDetails: make(map[string]api.SpamTestDetail),
|
||||||
}
|
}
|
||||||
analyzer.parseSpamStatus(tt.header, result)
|
analyzer.parseSpamStatus(tt.header, result)
|
||||||
|
|
||||||
|
|
@ -116,27 +115,27 @@ func TestParseSpamReport(t *testing.T) {
|
||||||
`
|
`
|
||||||
|
|
||||||
analyzer := NewSpamAssassinAnalyzer()
|
analyzer := NewSpamAssassinAnalyzer()
|
||||||
result := &model.SpamAssassinResult{
|
result := &api.SpamAssassinResult{
|
||||||
TestDetails: make(map[string]model.SpamTestDetail),
|
TestDetails: make(map[string]api.SpamTestDetail),
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer.parseSpamReport(report, result)
|
analyzer.parseSpamReport(report, result)
|
||||||
|
|
||||||
expectedTests := map[string]model.SpamTestDetail{
|
expectedTests := map[string]api.SpamTestDetail{
|
||||||
"BAYES_99": {
|
"BAYES_99": {
|
||||||
Name: "BAYES_99",
|
Name: "BAYES_99",
|
||||||
Score: 5.0,
|
Score: 5.0,
|
||||||
Description: utils.PtrTo("Bayes spam probability is 99 to 100%"),
|
Description: api.PtrTo("Bayes spam probability is 99 to 100%"),
|
||||||
},
|
},
|
||||||
"SPOOFED_SENDER": {
|
"SPOOFED_SENDER": {
|
||||||
Name: "SPOOFED_SENDER",
|
Name: "SPOOFED_SENDER",
|
||||||
Score: 3.5,
|
Score: 3.5,
|
||||||
Description: utils.PtrTo("From address doesn't match envelope sender"),
|
Description: api.PtrTo("From address doesn't match envelope sender"),
|
||||||
},
|
},
|
||||||
"ALL_TRUSTED": {
|
"ALL_TRUSTED": {
|
||||||
Name: "ALL_TRUSTED",
|
Name: "ALL_TRUSTED",
|
||||||
Score: -1.0,
|
Score: -1.0,
|
||||||
Description: utils.PtrTo("All mail servers are trusted"),
|
Description: api.PtrTo("All mail servers are trusted"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,7 +157,7 @@ func TestParseSpamReport(t *testing.T) {
|
||||||
func TestGetSpamAssassinScore(t *testing.T) {
|
func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
result *model.SpamAssassinResult
|
result *api.SpamAssassinResult
|
||||||
expectedScore int
|
expectedScore int
|
||||||
minScore int
|
minScore int
|
||||||
maxScore int
|
maxScore int
|
||||||
|
|
@ -170,7 +169,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Excellent score (negative)",
|
name: "Excellent score (negative)",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: -2.5,
|
Score: -2.5,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -178,7 +177,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Good score (below threshold)",
|
name: "Good score (below threshold)",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: 2.0,
|
Score: 2.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -186,7 +185,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Score at threshold",
|
name: "Score at threshold",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: 5.0,
|
Score: 5.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -194,7 +193,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Above threshold (spam)",
|
name: "Above threshold (spam)",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: 6.0,
|
Score: 6.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -202,7 +201,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "High spam score",
|
name: "High spam score",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: 12.0,
|
Score: 12.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -210,7 +209,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Very high spam score",
|
name: "Very high spam score",
|
||||||
result: &model.SpamAssassinResult{
|
result: &api.SpamAssassinResult{
|
||||||
Score: 20.0,
|
Score: 20.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
2386
web/package-lock.json
generated
2386
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,13 +17,13 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.0",
|
"@eslint/compat": "^2.0.0",
|
||||||
"@eslint/js": "^10.0.0",
|
"@eslint/js": "^9.36.0",
|
||||||
"@hey-api/openapi-ts": "0.86.10",
|
"@hey-api/openapi-ts": "0.86.10",
|
||||||
"@sveltejs/adapter-static": "^3.0.9",
|
"@sveltejs/adapter-static": "^3.0.9",
|
||||||
"@sveltejs/kit": "^2.43.2",
|
"@sveltejs/kit": "^2.43.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"eslint": "^10.0.0",
|
"eslint": "^9.38.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.12.4",
|
"eslint-plugin-svelte": "^3.12.4",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
|
|
@ -31,9 +31,9 @@
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^5.39.5",
|
"svelte": "^5.39.5",
|
||||||
"svelte-check": "^4.3.2",
|
"svelte-check": "^4.3.2",
|
||||||
"typescript": "^6.0.0",
|
"typescript": "^5.9.2",
|
||||||
"typescript-eslint": "^8.44.1",
|
"typescript-eslint": "^8.44.1",
|
||||||
"vite": "^8.0.0",
|
"vite": "^7.1.10",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
|
||||||
appConfig["custom_logo_url"] = cfg.CustomLogoURL
|
appConfig["custom_logo_url"] = cfg.CustomLogoURL
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cfg.DisableTestList {
|
|
||||||
appConfig["test_list_enabled"] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
|
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
|
||||||
log.Println("Unable to generate JSON config to inject in web application")
|
log.Println("Unable to generate JSON config to inject in web application")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -99,7 +95,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
|
||||||
router.GET("/domain/:domain", serveOrReverse("/", cfg))
|
router.GET("/domain/:domain", serveOrReverse("/", cfg))
|
||||||
router.GET("/test/", serveOrReverse("/", cfg))
|
router.GET("/test/", serveOrReverse("/", cfg))
|
||||||
router.GET("/test/:testid", serveOrReverse("/", cfg))
|
router.GET("/test/:testid", serveOrReverse("/", cfg))
|
||||||
router.GET("/history/", serveOrReverse("/", cfg))
|
|
||||||
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
||||||
router.GET("/img/*path", serveOrReverse("", cfg))
|
router.GET("/img/*path", serveOrReverse("", cfg))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,6 @@
|
||||||
|
|
||||||
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
|
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
|
||||||
|
|
||||||
let allRequiredMissing = $derived(
|
|
||||||
!authentication.spf &&
|
|
||||||
(!authentication.dkim || authentication.dkim.length === 0) &&
|
|
||||||
!authentication.dmarc,
|
|
||||||
);
|
|
||||||
|
|
||||||
function getAuthResultClass(result: string, noneIsFail: boolean): string {
|
function getAuthResultClass(result: string, noneIsFail: boolean): string {
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case "pass":
|
case "pass":
|
||||||
|
|
@ -103,28 +97,6 @@
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
{#if allRequiredMissing}
|
|
||||||
<div class="card-body border-bottom">
|
|
||||||
<div class="alert alert-warning mb-0">
|
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
|
||||||
<strong>No authentication results found.</strong>
|
|
||||||
<p class="mb-0 mt-1">
|
|
||||||
This usually means either:
|
|
||||||
</p>
|
|
||||||
<ul class="mb-0 mt-1">
|
|
||||||
<li>
|
|
||||||
The receiving mail server is not configured to verify email authentication
|
|
||||||
(no <code>Authentication-Results</code> header was found in the message).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
The <code>Authentication-Results</code> header exists but the receiver
|
|
||||||
hostname does not match the configured
|
|
||||||
<code>--receiver-hostname</code> value.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
<!-- IPREV -->
|
<!-- IPREV -->
|
||||||
{#if authentication.iprev}
|
{#if authentication.iprev}
|
||||||
|
|
@ -170,88 +142,6 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- X-Ptr (HELO / reverse DNS consistency) -->
|
|
||||||
{#if authentication.x_ptr}
|
|
||||||
<div class="list-group-item" id="authentication-x-ptr">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon(
|
|
||||||
authentication.x_ptr.result,
|
|
||||||
true,
|
|
||||||
)} {getAuthResultClass(authentication.x_ptr.result, true)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>HELO / PTR</strong>
|
|
||||||
<i
|
|
||||||
class="bi bi-info-circle text-muted ms-1"
|
|
||||||
title="Checks that the HELO/EHLO hostname announced by the sending server matches the sender IP's reverse DNS (PTR) record."
|
|
||||||
></i>
|
|
||||||
<span
|
|
||||||
class="text-uppercase ms-2 {getAuthResultClass(
|
|
||||||
authentication.x_ptr.result,
|
|
||||||
true,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{authentication.x_ptr.result}
|
|
||||||
</span>
|
|
||||||
{#if authentication.x_ptr.helo}
|
|
||||||
<div class="small">
|
|
||||||
<strong>Announced HELO:</strong>
|
|
||||||
<span class="text-muted">{authentication.x_ptr.helo}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authentication.x_ptr.ptr}
|
|
||||||
<div class="small">
|
|
||||||
<strong>Reverse DNS (PTR):</strong>
|
|
||||||
<span class="text-muted">{authentication.x_ptr.ptr}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if authentication.x_ptr.details}
|
|
||||||
<pre
|
|
||||||
class="p-2 mb-0 {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} text-muted small"
|
|
||||||
style="white-space: pre-wrap">{authentication.x_ptr.details}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- X-TLS (Transport encryption) -->
|
|
||||||
{#if authentication.x_tls}
|
|
||||||
<div class="list-group-item" id="authentication-x-tls">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon(
|
|
||||||
authentication.x_tls.result,
|
|
||||||
true,
|
|
||||||
)} {getAuthResultClass(authentication.x_tls.result, true)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>Transport TLS</strong>
|
|
||||||
<i
|
|
||||||
class="bi bi-info-circle text-muted ms-1"
|
|
||||||
title="Whether the inbound connection that delivered this message used TLS encryption (x-tls). Falls back to the inbound Received hop when no x-tls header is present."
|
|
||||||
></i>
|
|
||||||
<span
|
|
||||||
class="text-uppercase ms-2 {getAuthResultClass(
|
|
||||||
authentication.x_tls.result,
|
|
||||||
true,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{authentication.x_tls.result}
|
|
||||||
</span>
|
|
||||||
{#if authentication.x_tls.details}
|
|
||||||
<div class="small text-muted mt-1">
|
|
||||||
{authentication.x_tls.details}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- SPF (Required) -->
|
<!-- SPF (Required) -->
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex align-items-start" id="authentication-spf">
|
<div class="d-flex align-items-start" id="authentication-spf">
|
||||||
|
|
|
||||||
|
|
@ -72,26 +72,6 @@
|
||||||
{bimiRecord.error}
|
{bimiRecord.error}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !bimiRecord.valid}
|
|
||||||
<div class="alert alert-info mt-3 mb-0">
|
|
||||||
<h6 class="alert-heading">
|
|
||||||
<i class="bi bi-lightbulb me-1"></i>
|
|
||||||
Explicitly decline BIMI participation
|
|
||||||
</h6>
|
|
||||||
<p class="mb-2 small">
|
|
||||||
If you do not intend to publish a brand logo, you can add a declination
|
|
||||||
record to signal that this domain deliberately opts out of BIMI. This
|
|
||||||
prevents mail clients from falling back to a parent-domain record:
|
|
||||||
</p>
|
|
||||||
<code class="d-block bg-white rounded p-2 text-break border"
|
|
||||||
>{bimiRecord.selector}._bimi.{bimiRecord.domain}. IN TXT "v=BIMI1; l=; a="</code
|
|
||||||
>
|
|
||||||
<p class="mt-1 mb-0 small text-muted">
|
|
||||||
Declination record format as defined in § 4.3.1 of
|
|
||||||
<em>draft-brand-indicators-for-message-identification</em>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,23 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BlacklistCheck } from "$lib/api/types.gen";
|
import type { BlacklistCheck, ReceivedHop } from "$lib/api/types.gen";
|
||||||
import { getScoreColorClass } from "$lib/score";
|
import { getScoreColorClass } from "$lib/score";
|
||||||
import { theme } from "$lib/stores/theme";
|
import { theme } from "$lib/stores/theme";
|
||||||
|
import EmailPathCard from "./EmailPathCard.svelte";
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
import GradeDisplay from "./GradeDisplay.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
blacklists: Record<string, BlacklistCheck[]>;
|
blacklists: Record<string, BlacklistCheck[]>;
|
||||||
blacklistGrade?: string;
|
blacklistGrade?: string;
|
||||||
blacklistScore?: number;
|
blacklistScore?: number;
|
||||||
|
receivedChain?: ReceivedHop[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
|
let { blacklists, blacklistGrade, blacklistScore, receivedChain }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card shadow-sm" id="rbl-details">
|
<div class="card shadow-sm" id="rbl-details">
|
||||||
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
||||||
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
|
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-shield-exclamation me-2"></i>
|
<i class="bi bi-shield-exclamation me-2"></i>
|
||||||
Blacklist Checks
|
Blacklist Checks
|
||||||
|
|
@ -33,7 +35,11 @@
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
{#if receivedChain}
|
||||||
|
<EmailPathCard {receivedChain} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-lg-2">
|
||||||
{#each Object.entries(blacklists) as [ip, checks]}
|
{#each Object.entries(blacklists) as [ip, checks]}
|
||||||
<div class="col mb-3">
|
<div class="col mb-3">
|
||||||
<h5 class="text-muted">
|
<h5 class="text-muted">
|
||||||
|
|
|
||||||
|
|
@ -3,31 +3,15 @@
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dmarcRecord?: DmarcRecord;
|
dmarcRecord?: DmarcRecord;
|
||||||
fromDomain?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { dmarcRecord, fromDomain }: Props = $props();
|
let { dmarcRecord }: Props = $props();
|
||||||
|
|
||||||
const isFallback = $derived(
|
|
||||||
!!dmarcRecord?.domain && !!fromDomain && dmarcRecord.domain !== fromDomain,
|
|
||||||
);
|
|
||||||
// A single-label domain (no dot) is a TLD/PSD level fallback
|
|
||||||
const isPsdFallback = $derived(isFallback && !dmarcRecord?.domain?.includes("."));
|
|
||||||
|
|
||||||
// Helper function to determine policy strength
|
// Helper function to determine policy strength
|
||||||
const policyStrength = (policy: string | undefined): number => {
|
const policyStrength = (policy: string | undefined): number => {
|
||||||
const strength: Record<string, number> = { none: 0, quarantine: 1, reject: 2 };
|
const strength: Record<string, number> = { none: 0, quarantine: 1, reject: 2 };
|
||||||
return strength[policy || "none"] || 0;
|
return strength[policy || "none"] || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Effective policy after applying DMARCbis t=y downgrade
|
|
||||||
const effectivePolicy = $derived((): string => {
|
|
||||||
const p = dmarcRecord?.policy ?? "none";
|
|
||||||
if (!dmarcRecord?.test_mode) return p;
|
|
||||||
if (p === "reject") return "quarantine";
|
|
||||||
if (p === "quarantine") return "none";
|
|
||||||
return p;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if dmarcRecord}
|
{#if dmarcRecord}
|
||||||
|
|
@ -68,27 +52,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fallback domain notice -->
|
|
||||||
{#if isFallback}
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Record found at:</strong>
|
|
||||||
<code>{dmarcRecord.domain}</code>
|
|
||||||
<div class="alert alert-info mt-2 mb-0 small">
|
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
|
||||||
No DMARC record exists for <code>{fromDomain}</code>. The record above was
|
|
||||||
inherited from
|
|
||||||
{#if isPsdFallback}
|
|
||||||
the Public Suffix Domain <code>{dmarcRecord.domain}</code> via the DMARCbis
|
|
||||||
DNS Tree Walk (which obsoletes the RFC 9091 PSD DMARC experiment).
|
|
||||||
{:else}
|
|
||||||
the organizational domain <code>{dmarcRecord.domain}</code> via the
|
|
||||||
DMARCbis DNS Tree Walk (compatible with RFC 7489 organizational domain
|
|
||||||
fallback).
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Policy -->
|
<!-- Policy -->
|
||||||
{#if dmarcRecord.policy}
|
{#if dmarcRecord.policy}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|
@ -136,53 +99,6 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Test Mode (DMARCbis t= tag) -->
|
|
||||||
{#if dmarcRecord.test_mode}
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Test Mode:</strong>
|
|
||||||
<span class="badge bg-warning">t=y (active)</span>
|
|
||||||
<div class="alert alert-warning mt-2 mb-0 small">
|
|
||||||
<i class="bi bi-flask me-1"></i>
|
|
||||||
<strong>Test mode active</strong> — DMARCbis-compliant receivers will
|
|
||||||
downgrade the effective policy one level:
|
|
||||||
{#if dmarcRecord.policy === "reject"}
|
|
||||||
<code>p=reject</code> is applied as <code>p=quarantine</code>.
|
|
||||||
{:else if dmarcRecord.policy === "quarantine"}
|
|
||||||
<code>p=quarantine</code> is applied as <code>p=none</code> (no action taken).
|
|
||||||
{:else}
|
|
||||||
<code>p=none</code> is unaffected by test mode.
|
|
||||||
{/if}
|
|
||||||
Aggregate reports are still generated normally.
|
|
||||||
This tag replaces the deprecated <code>pct=</code> for gradual rollout.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- PSD tag (DMARCbis psd=) -->
|
|
||||||
{#if dmarcRecord.psd === "y"}
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Public Suffix Domain:</strong>
|
|
||||||
<span class="badge bg-info">psd=y</span>
|
|
||||||
<div class="alert alert-info mt-2 mb-0 small">
|
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
|
||||||
<strong>PSD declared</strong> — this domain is declared as a Public Suffix
|
|
||||||
Domain. DMARCbis-compliant receivers will apply this policy to subdomains
|
|
||||||
that have no DMARC record of their own when using the DNS Tree Walk algorithm.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if dmarcRecord.psd === "n"}
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Organizational Domain Boundary:</strong>
|
|
||||||
<span class="badge bg-info">psd=n</span>
|
|
||||||
<div class="alert alert-info mt-2 mb-0 small">
|
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
|
||||||
<strong>Org Domain declared</strong> — <code>psd=n</code> explicitly declares
|
|
||||||
this as an Organizational Domain boundary. Subdomains with separate DNS
|
|
||||||
delegation will use their own independent DMARCbis Tree Walk.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Subdomain Policy -->
|
<!-- Subdomain Policy -->
|
||||||
{#if dmarcRecord.subdomain_policy}
|
{#if dmarcRecord.subdomain_policy}
|
||||||
{@const mainStrength = policyStrength(dmarcRecord.policy)}
|
{@const mainStrength = policyStrength(dmarcRecord.policy)}
|
||||||
|
|
@ -226,43 +142,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Non-Existent Subdomain Policy (np tag, DMARCbis) -->
|
<!-- Percentage -->
|
||||||
{#if dmarcRecord.nonexistent_subdomain_policy}
|
|
||||||
{@const effectiveSubStrength = policyStrength(dmarcRecord.subdomain_policy ?? dmarcRecord.policy)}
|
|
||||||
{@const npStrength = policyStrength(dmarcRecord.nonexistent_subdomain_policy)}
|
|
||||||
<div class="mb-3">
|
|
||||||
<strong>Non-Existent Subdomain Policy:</strong>
|
|
||||||
<span
|
|
||||||
class="badge {dmarcRecord.nonexistent_subdomain_policy === 'reject'
|
|
||||||
? 'bg-success'
|
|
||||||
: dmarcRecord.nonexistent_subdomain_policy === 'quarantine'
|
|
||||||
? 'bg-warning'
|
|
||||||
: 'bg-secondary'}"
|
|
||||||
>
|
|
||||||
{dmarcRecord.nonexistent_subdomain_policy}
|
|
||||||
</span>
|
|
||||||
{#if npStrength >= effectiveSubStrength}
|
|
||||||
<div class="alert alert-success mt-2 mb-0 small">
|
|
||||||
<i class="bi bi-check-circle me-1"></i>
|
|
||||||
<strong>Good configuration</strong> — non-existent subdomain policy is equal to or stricter
|
|
||||||
than the effective subdomain policy.
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="alert alert-warning mt-2 mb-0 small">
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
||||||
<strong>Weaker protection for non-existent subdomains</strong> — consider setting
|
|
||||||
<code>np={dmarcRecord.subdomain_policy ?? dmarcRecord.policy}</code> to match your subdomain policy.
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="alert alert-info mt-2 mb-0 small">
|
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
|
||||||
The <code>np=</code> tag is introduced by <strong>DMARCbis</strong> (draft-ietf-dmarc-dmarcbis),
|
|
||||||
a draft RFC updating RFC 7489. Support may vary across mail receivers.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Percentage (pct=, deprecated in DMARCbis) -->
|
|
||||||
{#if dmarcRecord.percentage !== undefined}
|
{#if dmarcRecord.percentage !== undefined}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<strong>Enforcement Percentage:</strong>
|
<strong>Enforcement Percentage:</strong>
|
||||||
|
|
@ -275,35 +155,25 @@
|
||||||
>
|
>
|
||||||
{dmarcRecord.percentage}%
|
{dmarcRecord.percentage}%
|
||||||
</span>
|
</span>
|
||||||
<div class="alert alert-warning mt-2 mb-0 small">
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
||||||
<strong>Deprecated tag</strong> — the <code>pct=</code> tag is removed in
|
|
||||||
DMARCbis. Many receivers already ignore it. For gradual rollout, replace it
|
|
||||||
with <code>t=y</code> (test mode); for full enforcement, simply remove
|
|
||||||
<code>pct=</code> from your record.
|
|
||||||
{#if dmarcRecord.percentage === 0}
|
|
||||||
<br /><strong>pct=0 is an anti-pattern</strong> — it was widely misused
|
|
||||||
as a signal to bypass DMARC entirely, which is one reason the tag was
|
|
||||||
removed. Use <code>t=y</code> instead.
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if dmarcRecord.percentage === 100}
|
{#if dmarcRecord.percentage === 100}
|
||||||
<div class="alert alert-success mt-2 mb-0 small">
|
<div class="alert alert-success mt-2 mb-0 small">
|
||||||
<i class="bi bi-check-circle me-1"></i>
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
<strong>Full enforcement</strong> — all messages are subject to DMARC policy.
|
<strong>Full enforcement</strong> — all messages are subject to DMARC policy.
|
||||||
|
This provides maximum protection.
|
||||||
</div>
|
</div>
|
||||||
{:else if dmarcRecord.percentage > 0 && dmarcRecord.percentage >= 50}
|
{:else if dmarcRecord.percentage >= 50}
|
||||||
<div class="alert alert-warning mt-2 mb-0 small">
|
<div class="alert alert-warning mt-2 mb-0 small">
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
<strong>Partial enforcement</strong> — only {dmarcRecord.percentage}% of
|
<strong>Partial enforcement</strong> — only {dmarcRecord.percentage}% of
|
||||||
messages are subject to DMARC policy. Receivers ignoring pct= will apply
|
messages are subject to DMARC policy. Consider increasing to
|
||||||
the full policy regardless.
|
<code>pct=100</code> once you've validated your configuration.
|
||||||
</div>
|
</div>
|
||||||
{:else if dmarcRecord.percentage > 0}
|
{:else}
|
||||||
<div class="alert alert-danger mt-2 mb-0 small">
|
<div class="alert alert-danger mt-2 mb-0 small">
|
||||||
<i class="bi bi-x-circle me-1"></i>
|
<i class="bi bi-x-circle me-1"></i>
|
||||||
<strong>Low enforcement</strong> — only {dmarcRecord.percentage}% of
|
<strong>Low enforcement</strong> — only {dmarcRecord.percentage}% of
|
||||||
messages are protected. Receivers ignoring pct= will apply full policy.
|
messages are protected. Gradually increase to <code>pct=100</code> for full
|
||||||
|
protection.
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -389,30 +259,6 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Deprecated rf=/ri= tags -->
|
|
||||||
{#if dmarcRecord.deprecated_rf || dmarcRecord.deprecated_ri}
|
|
||||||
<div class="alert alert-warning mt-2 mb-3 small">
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
||||||
<strong>Deprecated tags detected</strong> — your record contains
|
|
||||||
{#if dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri}
|
|
||||||
<code>rf=</code> and <code>ri=</code> tags that are
|
|
||||||
{:else if dmarcRecord.deprecated_rf}
|
|
||||||
the <code>rf=</code> tag that is
|
|
||||||
{:else}
|
|
||||||
the <code>ri=</code> tag that is
|
|
||||||
{/if}
|
|
||||||
removed in DMARCbis. Modern receivers will ignore
|
|
||||||
{dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "them" : "it"}.
|
|
||||||
{#if dmarcRecord.deprecated_ri}
|
|
||||||
Aggregate reporting interval is now fixed at ≥ 24 hours regardless of
|
|
||||||
<code>ri=</code>.
|
|
||||||
{/if}
|
|
||||||
You can safely remove
|
|
||||||
{dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "these tags" : "this tag"}
|
|
||||||
from your DMARC record.
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
{#if dmarcRecord.error}
|
{#if dmarcRecord.error}
|
||||||
<div class="text-danger">
|
<div class="text-danger">
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,9 @@
|
||||||
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
|
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
|
||||||
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
|
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
import GradeDisplay from "./GradeDisplay.svelte";
|
||||||
import HeloPtrMatchDisplay from "./HeloPtrMatchDisplay.svelte";
|
|
||||||
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
|
||||||
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
|
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
|
||||||
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
|
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
|
||||||
import ReturnOkDisplay from "./ReturnOkDisplay.svelte";
|
|
||||||
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
|
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -94,16 +92,6 @@
|
||||||
{senderIp}
|
{senderIp}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- HELO / PTR Consistency -->
|
|
||||||
<HeloPtrMatchDisplay
|
|
||||||
heloHostname={dnsResults.helo_hostname ?? receivedChain?.[0]?.from}
|
|
||||||
ptrRecords={dnsResults.ptr_records}
|
|
||||||
heloPtrMatch={dnsResults.helo_ptr_match}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Return Address Reachability (ReturnOK) -->
|
|
||||||
<ReturnOkDisplay returnOk={dnsResults.return_ok} />
|
|
||||||
|
|
||||||
<hr class="my-4" />
|
<hr class="my-4" />
|
||||||
|
|
||||||
<!-- Return-Path Domain Section -->
|
<!-- Return-Path Domain Section -->
|
||||||
|
|
@ -154,7 +142,8 @@
|
||||||
</h4>
|
</h4>
|
||||||
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
|
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
|
||||||
<span class="badge bg-danger ms-2">
|
<span class="badge bg-danger ms-2">
|
||||||
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path domain
|
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path
|
||||||
|
domain
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -176,10 +165,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- DMARC Record -->
|
<!-- DMARC Record -->
|
||||||
<DmarcRecordDisplay
|
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
|
||||||
dmarcRecord={dnsResults.dmarc_record}
|
|
||||||
fromDomain={dnsResults.from_domain}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- BIMI Record -->
|
<!-- BIMI Record -->
|
||||||
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
|
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ReceivedHop } from "$lib/api/types.gen";
|
import type { ReceivedHop } from "$lib/api/types.gen";
|
||||||
import { theme } from "$lib/stores/theme";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
receivedChain: ReceivedHop[];
|
receivedChain: ReceivedHop[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { receivedChain }: Props = $props();
|
let { receivedChain }: Props = $props();
|
||||||
|
|
||||||
// Mirror of the backend protocolIndicatesTLS (RFC 3848): the transport keyword
|
|
||||||
// gains a trailing "S" when TLS was used (ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA...).
|
|
||||||
function protocolIndicatesTLS(withProto: string | undefined | null): boolean {
|
|
||||||
if (!withProto) return false;
|
|
||||||
const p = withProto.trim().toUpperCase();
|
|
||||||
return p.endsWith("S") || p.endsWith("SA");
|
|
||||||
}
|
|
||||||
|
|
||||||
// RFC 3848: a trailing "A" means the sender authenticated (SMTP AUTH):
|
|
||||||
// ESMTPA, ESMTPSA, LMTPA, LMTPSA...
|
|
||||||
function protocolIndicatesAuth(withProto: string | undefined | null): boolean {
|
|
||||||
if (!withProto) return false;
|
|
||||||
return withProto.trim().toUpperCase().endsWith("A");
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if receivedChain && receivedChain.length > 0}
|
{#if receivedChain && receivedChain.length > 0}
|
||||||
<div class="card shadow-sm" id="email-path">
|
<div class="mb-3" id="email-path">
|
||||||
<div
|
<h5>Email Path (Received Chain)</h5>
|
||||||
class="card-header"
|
<div class="list-group">
|
||||||
class:bg-white={$theme === "light"}
|
|
||||||
class:bg-dark={$theme !== "light"}
|
|
||||||
>
|
|
||||||
<h4 class="mb-0">
|
|
||||||
<i class="bi bi-pin-map me-2"></i>
|
|
||||||
Email Path
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
{#each receivedChain as hop, i}
|
{#each receivedChain as hop, i}
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
|
@ -55,7 +30,7 @@
|
||||||
: "-"}
|
: "-"}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
{#if hop.with || hop.id || hop.from}
|
{#if hop.with || hop.id}
|
||||||
<p class="mb-1 small d-flex gap-3">
|
<p class="mb-1 small d-flex gap-3">
|
||||||
{#if hop.with}
|
{#if hop.with}
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -75,63 +50,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="mb-0 small d-flex flex-wrap align-items-center gap-3">
|
|
||||||
{#if hop.tls}
|
|
||||||
<span class="badge bg-success">
|
|
||||||
<i class="bi bi-lock-fill me-1"></i>TLS
|
|
||||||
</span>
|
|
||||||
{#if hop.tls.version}
|
|
||||||
<span>
|
|
||||||
<span class="text-muted">Version:</span>
|
|
||||||
<code>{hop.tls.version}</code>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if hop.tls.cipher}
|
|
||||||
<span>
|
|
||||||
<span class="text-muted">Cipher:</span>
|
|
||||||
<code>{hop.tls.cipher}</code>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if hop.tls.bits}
|
|
||||||
<span>
|
|
||||||
<span class="text-muted">Strength:</span>
|
|
||||||
<code>{hop.tls.bits} bits</code>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if hop.tls.verified !== undefined}
|
|
||||||
<span
|
|
||||||
class:text-success={hop.tls.verified}
|
|
||||||
class:text-warning={!hop.tls.verified}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="bi {hop.tls.verified
|
|
||||||
? 'bi-patch-check-fill'
|
|
||||||
: 'bi-patch-exclamation-fill'} me-1"
|
|
||||||
></i>
|
|
||||||
{hop.tls.verified
|
|
||||||
? "Certificate trusted"
|
|
||||||
: "Certificate not trusted"}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{:else if protocolIndicatesTLS(hop.with)}
|
|
||||||
<span class="badge bg-success">
|
|
||||||
<i class="bi bi-lock-fill me-1"></i>TLS
|
|
||||||
</span>
|
|
||||||
{:else if hop.with}
|
|
||||||
<span class="badge bg-secondary">
|
|
||||||
<i class="bi bi-unlock me-1"></i>No TLS
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="badge bg-light text-muted border">
|
|
||||||
<i class="bi bi-question-circle me-1"></i>TLS unknown
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if protocolIndicatesAuth(hop.with)}
|
|
||||||
<span class="badge bg-info">
|
|
||||||
<i class="bi bi-person-check-fill me-1"></i>Authenticated
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
headerScore?: number;
|
headerScore?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
|
let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card shadow-sm" id="header-details">
|
<div class="card shadow-sm" id="header-details">
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
heloHostname?: string;
|
|
||||||
ptrRecords?: string[];
|
|
||||||
heloPtrMatch?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { heloHostname, ptrRecords, heloPtrMatch }: Props = $props();
|
|
||||||
|
|
||||||
const normalize = (host: string) => host.replace(/\.$/, "").trim().toLowerCase();
|
|
||||||
|
|
||||||
// Local comparison, identical to the per-record badge logic below, so the
|
|
||||||
// summary alert can never contradict the individual "Match" badges.
|
|
||||||
const localMatch = $derived(
|
|
||||||
!!heloHostname &&
|
|
||||||
!!ptrRecords &&
|
|
||||||
ptrRecords.some((ptr) => normalize(heloHostname) === normalize(ptr)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prefer the backend verdict when it is present; otherwise fall back to the
|
|
||||||
// local comparison (e.g. for results produced before helo_ptr_match existed).
|
|
||||||
const isMatch = $derived(heloPtrMatch ?? localMatch);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if heloHostname}
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="text-muted mb-0">
|
|
||||||
<i
|
|
||||||
class="bi"
|
|
||||||
class:bi-check-circle-fill={isMatch}
|
|
||||||
class:text-success={isMatch}
|
|
||||||
class:bi-x-circle-fill={!isMatch}
|
|
||||||
class:text-danger={!isMatch}
|
|
||||||
></i>
|
|
||||||
HELO / PTR Consistency
|
|
||||||
</h5>
|
|
||||||
<span class="badge bg-secondary">HELO</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="card-text small text-muted mb-0">
|
|
||||||
The HELO/EHLO hostname is the name the sending server announces when it connects.
|
|
||||||
Many mail servers check that this name matches the sender IP's reverse DNS (PTR)
|
|
||||||
record. A mismatch is a common spam signal and can hurt deliverability.
|
|
||||||
</p>
|
|
||||||
<div class="mt-2">
|
|
||||||
<strong>Announced HELO:</strong> <code>{heloHostname}</code>
|
|
||||||
</div>
|
|
||||||
{#if ptrRecords && ptrRecords.length > 0}
|
|
||||||
<div class="mt-1">
|
|
||||||
<strong>PTR Hostname(s):</strong>
|
|
||||||
{#each ptrRecords as ptr}
|
|
||||||
<div class="d-flex gap-2 align-items-center mt-1">
|
|
||||||
{#if normalize(heloHostname) === normalize(ptr)}
|
|
||||||
<span class="badge bg-success">Match</span>
|
|
||||||
{:else}
|
|
||||||
<span class="badge bg-secondary">Different</span>
|
|
||||||
{/if}
|
|
||||||
<code>{ptr}</code>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if !isMatch}
|
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
<div class="list-group-item">
|
|
||||||
<div class="alert alert-warning mb-0">
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
||||||
<strong>Warning:</strong> The announced HELO hostname
|
|
||||||
<code>{heloHostname}</code>
|
|
||||||
{#if ptrRecords && ptrRecords.length > 0}
|
|
||||||
does not match the sender's PTR record{ptrRecords.length > 1 ? "s" : ""}
|
|
||||||
({#each ptrRecords as ptr, i}<code>{ptr}</code>{i <
|
|
||||||
ptrRecords.length - 1
|
|
||||||
? ", "
|
|
||||||
: ""}{/each}).
|
|
||||||
{:else}
|
|
||||||
could not be matched against a PTR record.
|
|
||||||
{/if}
|
|
||||||
Configuring the HELO name to match reverse DNS improves deliverability.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
import type { TestSummary } from "$lib/api/types.gen";
|
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tests: TestSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let { tests }: Props = $props();
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="table-responsive shadow-sm">
|
|
||||||
<table class="table table-hover mb-0 align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="ps-4" style="width: 80px;">Grade</th>
|
|
||||||
<th style="width: 80px;">Score</th>
|
|
||||||
<th>Domain</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th style="width: 50px;"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each tests as test}
|
|
||||||
<tr class="cursor-pointer" onclick={() => goto(`/test/${test.test_id}`)}>
|
|
||||||
<td class="ps-4">
|
|
||||||
<GradeDisplay grade={test.grade} size="small" />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-secondary">{test.score}%</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{#if test.from_domain}
|
|
||||||
<code>{test.from_domain}</code>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted">-</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">
|
|
||||||
{formatDate(test.created_at)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<i class="bi bi-chevron-right text-muted"></i>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.cursor-pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cursor-pointer:hover td {
|
|
||||||
background-color: var(--bs-tertiary-bg);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { SchemasReturnOk, SchemasReturnOkDomain } from "$lib/api/types.gen";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
returnOk?: SchemasReturnOk;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { returnOk }: Props = $props();
|
|
||||||
|
|
||||||
type Row = { label: string; entry: SchemasReturnOkDomain };
|
|
||||||
|
|
||||||
const rows = $derived<Row[]>(
|
|
||||||
[
|
|
||||||
returnOk?.from ? { label: "From", entry: returnOk.from } : undefined,
|
|
||||||
returnOk?.return_path
|
|
||||||
? { label: "Return-Path", entry: returnOk.return_path }
|
|
||||||
: undefined,
|
|
||||||
].filter((r): r is Row => r !== undefined),
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasFail = $derived(rows.some((r) => r.entry.status === "fail"));
|
|
||||||
const hasWarn = $derived(rows.some((r) => r.entry.status === "warn"));
|
|
||||||
const allPass = $derived(rows.length > 0 && rows.every((r) => r.entry.status === "pass"));
|
|
||||||
|
|
||||||
// Header icon reflects the worst status across the checked domains.
|
|
||||||
const headerOk = $derived(allPass);
|
|
||||||
|
|
||||||
function badgeClass(status: string): string {
|
|
||||||
if (status === "pass") return "bg-success";
|
|
||||||
if (status === "warn") return "bg-warning text-dark";
|
|
||||||
return "bg-danger";
|
|
||||||
}
|
|
||||||
|
|
||||||
function badgeLabel(status: string): string {
|
|
||||||
if (status === "pass") return "MX";
|
|
||||||
if (status === "warn") return "A/AAAA only";
|
|
||||||
return "Unreachable";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if rows.length > 0}
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="text-muted mb-0">
|
|
||||||
<i
|
|
||||||
class="bi"
|
|
||||||
class:bi-check-circle-fill={headerOk}
|
|
||||||
class:text-success={headerOk}
|
|
||||||
class:bi-exclamation-triangle-fill={!headerOk && !hasFail}
|
|
||||||
class:text-warning={!headerOk && !hasFail}
|
|
||||||
class:bi-x-circle-fill={hasFail}
|
|
||||||
class:text-danger={hasFail}
|
|
||||||
></i>
|
|
||||||
Return Address Reachability
|
|
||||||
</h5>
|
|
||||||
<span class="badge bg-secondary">RETURN-OK</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="card-text small text-muted mb-0">
|
|
||||||
Replies (to the From address) and bounces (to the Return-Path) can only be delivered
|
|
||||||
if the sender's domains accept mail. A domain should publish MX records; an A/AAAA
|
|
||||||
record works as an implicit fallback but is not recommended. A domain with neither
|
|
||||||
is unreachable and silently drops replies and bounces.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
{#each rows as { label, entry } (label)}
|
|
||||||
<div class="list-group-item">
|
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
||||||
<span class="text-muted" style="min-width: 6.5rem">{label} domain:</span>
|
|
||||||
<code>{entry.domain}</code>
|
|
||||||
<span class="badge {badgeClass(entry.status)}">
|
|
||||||
{badgeLabel(entry.status)}
|
|
||||||
</span>
|
|
||||||
{#if entry.org_domain}
|
|
||||||
<small class="text-muted">
|
|
||||||
via organizational domain <code>{entry.org_domain}</code>
|
|
||||||
</small>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if hasFail || hasWarn}
|
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
<div class="list-group-item">
|
|
||||||
{#if hasFail}
|
|
||||||
<div class="alert alert-danger mb-0">
|
|
||||||
<i class="bi bi-x-circle me-1"></i>
|
|
||||||
<strong>Error:</strong> At least one sender domain has no MX and no A/AAAA record.
|
|
||||||
Replies or bounce messages to that domain will be lost. Publish an MX record pointing
|
|
||||||
to a mail server that accepts mail.
|
|
||||||
</div>
|
|
||||||
{:else if hasWarn}
|
|
||||||
<div class="alert alert-warning mb-0">
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
||||||
<strong>Warning:</strong> A sender domain has no MX record and relies on its A/AAAA
|
|
||||||
record (implicit MX). Mail is still deliverable, but publishing an explicit MX
|
|
||||||
record is recommended.
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
@ -17,7 +17,8 @@
|
||||||
|
|
||||||
const effectiveAction = $derived.by(() => {
|
const effectiveAction = $derived.by(() => {
|
||||||
const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15;
|
const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15;
|
||||||
if (rspamd.score >= rejectThreshold) return { label: "Reject", cls: "bg-danger" };
|
if (rspamd.score >= rejectThreshold)
|
||||||
|
return { label: "Reject", cls: "bg-danger" };
|
||||||
if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD)
|
if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD)
|
||||||
return { label: "Add header", cls: "bg-warning text-dark" };
|
return { label: "Add header", cls: "bg-warning text-dark" };
|
||||||
if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD)
|
if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD)
|
||||||
|
|
@ -30,7 +31,7 @@
|
||||||
<div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}">
|
<div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}">
|
||||||
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-bug me-2"></i>
|
<i class="bi bi-shield-exclamation me-2"></i>
|
||||||
rspamd Analysis
|
rspamd Analysis
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -75,7 +76,7 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>Symbol</th>
|
<th>Symbol</th>
|
||||||
<th class="text-end">Score</th>
|
<th class="text-end">Score</th>
|
||||||
<th>Description</th>
|
<th>Parameters</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -87,14 +88,7 @@
|
||||||
? "table-success"
|
? "table-success"
|
||||||
: ""}
|
: ""}
|
||||||
>
|
>
|
||||||
<td>
|
<td class="font-monospace">{symbolName}</td>
|
||||||
<span class="font-monospace">{symbolName}</span>
|
|
||||||
{#if symbol.params}
|
|
||||||
<small class="d-block text-muted">
|
|
||||||
{symbol.params}
|
|
||||||
</small>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<span
|
<span
|
||||||
class={symbol.score > 0
|
class={symbol.score > 0
|
||||||
|
|
@ -106,7 +100,7 @@
|
||||||
{symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)}
|
{symbol.score > 0 ? "+" : ""}{symbol.score.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="small text-muted">{symbol.description ?? ""}</td>
|
<td class="small text-muted">{symbol.params ?? ""}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -114,32 +108,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if rspamd.report}
|
|
||||||
<details class="mt-3">
|
|
||||||
<summary class="cursor-pointer fw-bold">Raw Report</summary>
|
|
||||||
<pre
|
|
||||||
class="mt-2 small {$theme === 'light'
|
|
||||||
? 'bg-light'
|
|
||||||
: 'bg-secondary'} p-3 rounded">{rspamd.report}</pre>
|
|
||||||
</details>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.cursor-pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
details summary {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
details summary:hover {
|
|
||||||
color: var(--bs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Darker table colors in dark mode */
|
/* Darker table colors in dark mode */
|
||||||
:global([data-bs-theme="dark"]) .table-warning {
|
:global([data-bs-theme="dark"]) .table-warning {
|
||||||
--bs-table-bg: rgba(255, 193, 7, 0.2);
|
--bs-table-bg: rgba(255, 193, 7, 0.2);
|
||||||
|
|
|
||||||
|
|
@ -25,32 +25,16 @@
|
||||||
|
|
||||||
// Email sender information
|
// Email sender information
|
||||||
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
||||||
const hasDkim =
|
const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0;
|
||||||
report.dns_results?.dkim_records && report.dns_results?.dkim_records?.length > 0;
|
const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass");
|
||||||
const dkimPassed =
|
|
||||||
report.authentication?.dkim &&
|
|
||||||
report.authentication?.dkim.length > 0 &&
|
|
||||||
report.authentication?.dkim?.some((d) => d.result === "pass");
|
|
||||||
|
|
||||||
segments.push({ text: "Received a " });
|
segments.push({ text: "Received a " });
|
||||||
segments.push({
|
segments.push({
|
||||||
text: hasDkim ? "DKIM-signed" : "non-DKIM-signed",
|
text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed",
|
||||||
highlight: {
|
highlight: { color: dkimPassed ? "good" : "danger", bold: true },
|
||||||
color: hasDkim ? (dkimPassed ? "good" : "warning") : "danger",
|
|
||||||
bold: true,
|
|
||||||
},
|
|
||||||
link: hasDkim && dkimPassed ? "#authentication-dkim" : "#dns-details",
|
|
||||||
});
|
|
||||||
segments.push({ text: " email" });
|
|
||||||
if (hasDkim && !dkimPassed) {
|
|
||||||
segments.push({ text: " with " });
|
|
||||||
segments.push({
|
|
||||||
text: "an invalid signature",
|
|
||||||
highlight: { color: "danger", bold: true },
|
|
||||||
link: "#authentication-dkim",
|
link: "#authentication-dkim",
|
||||||
});
|
});
|
||||||
}
|
segments.push({ text: " email from " });
|
||||||
segments.push({ text: " from " });
|
|
||||||
segments.push({
|
segments.push({
|
||||||
text: mailFrom,
|
text: mailFrom,
|
||||||
highlight: { emphasis: true },
|
highlight: { emphasis: true },
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
<div class="card shadow-sm" id="dnswl-details">
|
<div class="card shadow-sm" id="dnswl-details">
|
||||||
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
|
||||||
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
|
<h4 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<span>
|
<span>
|
||||||
<i class="bi bi-shield-check me-2"></i>
|
<i class="bi bi-shield-check me-2"></i>
|
||||||
Whitelist Checks
|
Whitelist Checks
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
no impact on the overall score.
|
no impact on the overall score.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
|
<div class="row row-cols-1 row-cols-lg-2">
|
||||||
{#each Object.entries(whitelists) as [ip, checks]}
|
{#each Object.entries(whitelists) as [ip, checks]}
|
||||||
<div class="col mb-3">
|
<div class="col mb-3">
|
||||||
<h5 class="text-muted">
|
<h5 class="text-muted">
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,5 @@ export { default as RspamdCard } from "./RspamdCard.svelte";
|
||||||
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
||||||
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
|
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
|
||||||
export { default as SummaryCard } from "./SummaryCard.svelte";
|
export { default as SummaryCard } from "./SummaryCard.svelte";
|
||||||
export { default as HistoryTable } from "./HistoryTable.svelte";
|
|
||||||
export { default as TinySurvey } from "./TinySurvey.svelte";
|
export { default as TinySurvey } from "./TinySurvey.svelte";
|
||||||
export { default as WhitelistCard } from "./WhitelistCard.svelte";
|
export { default as WhitelistCard } from "./WhitelistCard.svelte";
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ interface AppConfig {
|
||||||
survey_url?: string;
|
survey_url?: string;
|
||||||
custom_logo_url?: string;
|
custom_logo_url?: string;
|
||||||
rbls?: string[];
|
rbls?: string[];
|
||||||
test_list_enabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: AppConfig = {
|
const defaultConfig: AppConfig = {
|
||||||
|
|
|
||||||
|
|
@ -40,17 +40,7 @@
|
||||||
<Logo color={$theme === "light" ? "black" : "white"} />
|
<Logo color={$theme === "light" ? "black" : "white"} />
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{#if $appConfig.test_list_enabled}
|
<div>
|
||||||
<ul class="navbar-nav me-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/history/">
|
|
||||||
<i class="bi bi-clock-history me-1"></i>
|
|
||||||
History
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<span class="d-none d-md-inline navbar-text text-primary small">
|
<span class="d-none d-md-inline navbar-text text-primary small">
|
||||||
Open-Source Email Deliverability Tester
|
Open-Source Email Deliverability Tester
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
import { createTest as apiCreateTest, listTests } from "$lib/api";
|
import { createTest as apiCreateTest } from "$lib/api";
|
||||||
import type { TestSummary } from "$lib/api/types.gen";
|
import { FeatureCard, HowItWorksStep } from "$lib/components";
|
||||||
import { FeatureCard, HowItWorksStep, HistoryTable } from "$lib/components";
|
|
||||||
import { appConfig } from "$lib/stores/config";
|
import { appConfig } from "$lib/stores/config";
|
||||||
|
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let recentTests = $state<TestSummary[]>([]);
|
|
||||||
|
|
||||||
async function loadRecentTests() {
|
|
||||||
if (!$appConfig.test_list_enabled) return;
|
|
||||||
try {
|
|
||||||
const response = await listTests({ query: { offset: 0, limit: 5 } });
|
|
||||||
if (response.data) {
|
|
||||||
recentTests = response.data.tests;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently ignore — this is a non-critical section
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
loadRecentTests();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createTest() {
|
async function createTest() {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
@ -194,32 +176,6 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Recently Tested -->
|
|
||||||
{#if $appConfig.test_list_enabled && recentTests.length > 0}
|
|
||||||
<section class="py-5 border-bottom border-3" id="recent">
|
|
||||||
<div class="container py-4">
|
|
||||||
<div class="row text-center mb-5">
|
|
||||||
<div class="col-lg-8 mx-auto">
|
|
||||||
<h2 class="display-5 fw-bold mb-3">Recently Tested</h2>
|
|
||||||
<p class="text-muted">Latest deliverability reports from this instance</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-10 mx-auto">
|
|
||||||
<HistoryTable tests={recentTests} />
|
|
||||||
<div class="text-center mt-4">
|
|
||||||
<a href="/history/" class="btn btn-outline-primary">
|
|
||||||
<i class="bi bi-clock-history me-2"></i>
|
|
||||||
View All Tests
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Features Section -->
|
<!-- Features Section -->
|
||||||
<section class="py-5" id="features">
|
<section class="py-5" id="features">
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { checkBlacklist } from "$lib/api";
|
import { checkBlacklist } from "$lib/api";
|
||||||
import type { BlacklistCheckResponse } from "$lib/api/types.gen";
|
import type { BlacklistCheckResponse } from "$lib/api/types.gen";
|
||||||
import { BlacklistCard, GradeDisplay, TinySurvey, WhitelistCard } from "$lib/components";
|
import { BlacklistCard, GradeDisplay, TinySurvey } from "$lib/components";
|
||||||
import { theme } from "$lib/stores/theme";
|
import { theme } from "$lib/stores/theme";
|
||||||
|
|
||||||
let ip = $derived($page.params.ip);
|
let ip = $derived($page.params.ip);
|
||||||
|
|
@ -122,8 +122,8 @@
|
||||||
>
|
>
|
||||||
<p class="mb-0 mt-1 small">
|
<p class="mb-0 mt-1 small">
|
||||||
This IP address is listed on {result.listed_count} of
|
This IP address is listed on {result.listed_count} of
|
||||||
{result.blacklists.length} checked blacklist{result
|
{result.checks.length} checked blacklist{result
|
||||||
.blacklists.length > 1
|
.checks.length > 1
|
||||||
? "s"
|
? "s"
|
||||||
: ""}.
|
: ""}.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -150,23 +150,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<!-- Blacklist Results Card -->
|
<!-- Blacklist Results Card -->
|
||||||
<div class="col col-lg-6">
|
|
||||||
<BlacklistCard
|
<BlacklistCard
|
||||||
blacklists={{ [result.ip]: result.blacklists }}
|
blacklists={{ [result.ip]: result.checks }}
|
||||||
blacklistScore={result.score}
|
blacklistScore={result.score}
|
||||||
blacklistGrade={result.grade}
|
blacklistGrade={result.grade}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Whitelist Results Card -->
|
|
||||||
{#if result.whitelists && result.whitelists.length > 0}
|
|
||||||
<div class="col col-lg-6">
|
|
||||||
<WhitelistCard whitelists={{ [result.ip]: result.whitelists }} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Information Card -->
|
<!-- Information Card -->
|
||||||
<div class="card shadow-sm mt-4">
|
<div class="card shadow-sm mt-4">
|
||||||
|
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
import { listTests, createTest as apiCreateTest } from "$lib/api";
|
|
||||||
import type { TestSummary } from "$lib/api/types.gen";
|
|
||||||
import { HistoryTable } from "$lib/components";
|
|
||||||
|
|
||||||
let tests = $state<TestSummary[]>([]);
|
|
||||||
let total = $state(0);
|
|
||||||
let offset = $state(0);
|
|
||||||
let limit = $state(20);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
let creatingTest = $state(false);
|
|
||||||
|
|
||||||
async function loadTests() {
|
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await listTests({ query: { offset, limit } });
|
|
||||||
if (response.data) {
|
|
||||||
tests = response.data.tests;
|
|
||||||
total = response.data.total;
|
|
||||||
} else if (response.error) {
|
|
||||||
if (
|
|
||||||
response.error &&
|
|
||||||
typeof response.error === "object" &&
|
|
||||||
"error" in response.error &&
|
|
||||||
response.error.error === "feature_disabled"
|
|
||||||
) {
|
|
||||||
error = "Test listing is disabled on this instance.";
|
|
||||||
} else {
|
|
||||||
error = "Failed to load tests.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : "Failed to load tests.";
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
loadTests();
|
|
||||||
});
|
|
||||||
|
|
||||||
function goToPage(newOffset: number) {
|
|
||||||
offset = newOffset;
|
|
||||||
loadTests();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTest() {
|
|
||||||
creatingTest = true;
|
|
||||||
try {
|
|
||||||
const response = await apiCreateTest();
|
|
||||||
if (response.data) {
|
|
||||||
goto(`/test/${response.data.id}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : "Failed to create test";
|
|
||||||
} finally {
|
|
||||||
creatingTest = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalPages = $derived(Math.ceil(total / limit));
|
|
||||||
let currentPage = $derived(Math.floor(offset / limit) + 1);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Test History - happyDeliver</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="container py-5">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-10 mx-auto">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1 class="display-6 fw-bold mb-0">
|
|
||||||
<i class="bi bi-clock-history me-2"></i>
|
|
||||||
Test History
|
|
||||||
</h1>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
onclick={createTest}
|
|
||||||
disabled={creatingTest}
|
|
||||||
>
|
|
||||||
{#if creatingTest}
|
|
||||||
<span
|
|
||||||
class="spinner-border spinner-border-sm me-2"
|
|
||||||
role="status"
|
|
||||||
></span>
|
|
||||||
{:else}
|
|
||||||
<i class="bi bi-plus-lg me-1"></i>
|
|
||||||
{/if}
|
|
||||||
New Test
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div
|
|
||||||
class="spinner-border text-primary"
|
|
||||||
role="status"
|
|
||||||
style="width: 3rem; height: 3rem;"
|
|
||||||
>
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-3 text-muted">Loading tests...</p>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="alert alert-warning text-center" role="alert">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{:else if tests.length === 0}
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i
|
|
||||||
class="bi bi-inbox display-1 text-muted mb-3 d-block"
|
|
||||||
></i>
|
|
||||||
<h2 class="h4 text-muted mb-3">No tests yet</h2>
|
|
||||||
<p class="text-muted mb-4">
|
|
||||||
Send a test email to get your first deliverability
|
|
||||||
report.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary btn-lg"
|
|
||||||
onclick={createTest}
|
|
||||||
disabled={creatingTest}
|
|
||||||
>
|
|
||||||
<i class="bi bi-envelope-plus me-2"></i>
|
|
||||||
Start Your First Test
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<HistoryTable {tests} />
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{#if totalPages > 1}
|
|
||||||
<nav class="mt-4 d-flex justify-content-between align-items-center">
|
|
||||||
<small class="text-muted">
|
|
||||||
Showing {offset + 1}-{Math.min(
|
|
||||||
offset + limit,
|
|
||||||
total,
|
|
||||||
)} of {total} tests
|
|
||||||
</small>
|
|
||||||
<ul class="pagination mb-0">
|
|
||||||
<li
|
|
||||||
class="page-item"
|
|
||||||
class:disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="page-link"
|
|
||||||
onclick={() =>
|
|
||||||
goToPage(
|
|
||||||
Math.max(0, offset - limit),
|
|
||||||
)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<i class="bi bi-chevron-left"></i>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="page-item disabled">
|
|
||||||
<span class="page-link">
|
|
||||||
Page {currentPage} of {totalPages}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
class="page-item"
|
|
||||||
class:disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="page-link"
|
|
||||||
onclick={() =>
|
|
||||||
goToPage(offset + limit)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<i class="bi bi-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
BlacklistCard,
|
BlacklistCard,
|
||||||
ContentAnalysisCard,
|
ContentAnalysisCard,
|
||||||
DnsRecordsCard,
|
DnsRecordsCard,
|
||||||
EmailPathCard,
|
|
||||||
ErrorDisplay,
|
ErrorDisplay,
|
||||||
HeaderAnalysisCard,
|
HeaderAnalysisCard,
|
||||||
PendingState,
|
PendingState,
|
||||||
|
|
@ -295,15 +294,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Received Chain -->
|
|
||||||
{#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0}
|
|
||||||
<div class="row mb-4" id="received-chain">
|
|
||||||
<div class="col-12">
|
|
||||||
<EmailPathCard receivedChain={report.header_analysis.received_chain} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- DNS Records -->
|
<!-- DNS Records -->
|
||||||
{#if report.dns_results}
|
{#if report.dns_results}
|
||||||
<div class="row mb-4" id="dns">
|
<div class="row mb-4" id="dns">
|
||||||
|
|
@ -339,6 +329,7 @@
|
||||||
{blacklists}
|
{blacklists}
|
||||||
blacklistGrade={report.summary?.blacklist_grade}
|
blacklistGrade={report.summary?.blacklist_grade}
|
||||||
blacklistScore={report.summary?.blacklist_score}
|
blacklistScore={report.summary?.blacklist_score}
|
||||||
|
receivedChain={report.header_analysis?.received_chain}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
|
@ -393,12 +384,12 @@
|
||||||
{#if report.spamassassin || report.rspamd}
|
{#if report.spamassassin || report.rspamd}
|
||||||
<div class="row mb-4" id="spam">
|
<div class="row mb-4" id="spam">
|
||||||
{#if report.spamassassin}
|
{#if report.spamassassin}
|
||||||
<div class={report.rspamd ? "col col-lg-6 mb-4 mb-lg-0" : "col-12"}>
|
<div class={report.rspamd ? "col-lg-6 mb-4 mb-lg-0" : "col-12"}>
|
||||||
<SpamAssassinCard spamassassin={report.spamassassin} />
|
<SpamAssassinCard spamassassin={report.spamassassin} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if report.rspamd}
|
{#if report.rspamd}
|
||||||
<div class={report.spamassassin ? "col col-lg-6" : "col-12"}>
|
<div class={report.spamassassin ? "col-lg-6" : "col-12"}>
|
||||||
<RspamdCard rspamd={report.rspamd} />
|
<RspamdCard rspamd={report.rspamd} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue