Compare commits

..

24 commits

Author SHA1 Message Date
84cec363e2 Update dependency @sveltejs/kit to v2.47.2 2025-10-20 04:09:39 +00:00
4ed08c5fa9 Add a banner in README.md
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-20 10:36:34 +07:00
b47676f234 Lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-20 02:28:18 +00:00
f1b9ac1e27 Fix spamassassin report details
Some checks are pending
continuous-integration/drone/push Build is running
2025-10-20 09:27:42 +07:00
fedb80f7d4 Expose analyzer 2025-10-20 07:40:52 +07:00
01569d1b21 Refactor authentication.go 2025-10-19 18:39:21 +07:00
78c070cdcf Implement ARC header check 2025-10-19 18:26:37 +07:00
74866d210c Implement BIMI checks 2025-10-19 18:11:23 +07:00
35ff54b2e1 go mod tidy
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-19 16:42:34 +07:00
fd5054f886 Update module golang.org/x/net to v0.46.0 2025-10-19 16:42:34 +07:00
50b72bb492 Add an auto-cleanup worker 2025-10-19 16:42:34 +07:00
19d9c6c859 Update dependency @hey-api/openapi-ts to v0.85.2 2025-10-19 16:42:34 +07:00
37babf1ad6 Add CI/CD 2025-10-19 16:42:34 +07:00
aa0d571d77 Add an auto-cleanup worker 2025-10-19 16:42:34 +07:00
6dce13cf51 Update dependency @hey-api/openapi-ts to v0.85.2 2025-10-19 16:42:34 +07:00
d178c1c440 Update module github.com/quic-go/quic-go to v0.54.1 [SECURITY] 2025-10-19 16:42:34 +07:00
f327733009 Add renovate.json 2025-10-19 16:42:34 +07:00
9d80e5e401 Create test on email arrival 2025-10-19 16:42:34 +07:00
7049e2d012 Add LMTP server 2025-10-19 16:42:34 +07:00
ef433bfbe5 Refactor main.go 2025-10-19 16:42:34 +07:00
54ad0e1151 Implement web ui 2025-10-19 16:42:34 +07:00
b96af8349d Web UI setup 2025-10-19 16:42:34 +07:00
4f0d0a7e83 Add AIO Dockerfile 2025-10-19 16:42:34 +07:00
7161eaaafd Glue things together 2025-10-19 15:47:38 +07:00
143 changed files with 8489 additions and 26274 deletions

View file

@ -9,7 +9,7 @@ platform:
steps:
- name: frontend
image: node:24-alpine
image: node:22-alpine
commands:
- cd web
- npm install --network-timeout=100000
@ -21,7 +21,7 @@ steps:
commands:
- apk add --no-cache git
- go generate ./...
- go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver
- go build -tags netgo -ldflags '-w -X main.Version=${DRONE_BRANCH}-${DRONE_COMMIT} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver
- ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver
environment:
CGO_ENABLED: 0
@ -35,7 +35,7 @@ steps:
commands:
- apk add --no-cache git
- go generate ./...
- go build -tags netgo -ldflags '-w -X git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
- go build -tags netgo -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
- ln happydeliver-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydeliver
environment:
CGO_ENABLED: 0
@ -47,7 +47,7 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
- go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
environment:
CGO_ENABLED: 0
GOOS: darwin
@ -61,7 +61,7 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo -ldflags '-w -X "git.happydns.org/happyDeliver/internal/version.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
- go build -tags netgo -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydeliver-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDeliver/
environment:
CGO_ENABLED: 0
GOOS: darwin

4
.gitignore vendored
View file

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

View file

@ -1,6 +1,6 @@
# Multi-stage Dockerfile for happyDeliver with integrated MTA
# Stage 1: Build the Svelte application
FROM node:24-alpine AS nodebuild
FROM node:22-alpine AS nodebuild
WORKDIR /build
@ -31,97 +31,19 @@ COPY --from=nodebuild /build/web/build/ ./web/build/
RUN go generate ./... && \
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver
# Stage 3: Prepare perl and spamass-milt
FROM alpine:3 AS pl
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
apk add --no-cache \
build-base \
libmilter-dev \
musl-obstack-dev \
openssl \
openssl-dev \
perl-app-cpanminus \
perl-alien-libxml2 \
perl-class-load-xs \
perl-cpanel-json-xs \
perl-crypt-openssl-rsa \
perl-crypt-openssl-random \
perl-crypt-openssl-verify \
perl-crypt-openssl-x509 \
perl-dbd-sqlite \
perl-dbi \
perl-email-address-xs \
perl-json-xs \
perl-list-moreutils \
perl-moose \
perl-net-idn-encode@edge \
perl-net-ssleay \
perl-netaddr-ip \
perl-package-stash \
perl-params-util \
perl-params-validate \
perl-proc-processtable \
perl-sereal-decoder \
perl-sereal-encoder \
perl-socket6 \
perl-sub-identify \
perl-variable-magic \
perl-xml-libxml \
perl-dev \
spamassassin-client \
zlib-dev \
&& \
ln -s /usr/bin/ld /bin/ld
RUN cpanm --notest Mail::SPF && \
cpanm --notest Mail::Milter::Authentication
RUN wget https://download.savannah.nongnu.org/releases/spamass-milt/spamass-milter-0.4.0.tar.gz && \
tar xzf spamass-milter-0.4.0.tar.gz && \
cd spamass-milter-0.4.0 && \
./configure && make install
# Stage 4: Runtime image with Postfix and all filters
# Stage 3: Runtime image with Postfix and all filters
FROM alpine:3
# Install all required packages
RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
apk add --no-cache \
RUN apk add --no-cache \
bash \
ca-certificates \
libmilter \
openssl \
perl \
perl-alien-libxml2 \
perl-class-load-xs \
perl-cpanel-json-xs \
perl-crypt-openssl-rsa \
perl-crypt-openssl-random \
perl-crypt-openssl-verify \
perl-crypt-openssl-x509 \
perl-dbd-sqlite \
perl-dbi \
perl-email-address-xs \
perl-json-xs \
perl-list-moreutils \
perl-moose \
perl-net-idn-encode@edge \
perl-net-ssleay \
perl-netaddr-ip \
perl-package-stash \
perl-params-util \
perl-params-validate \
perl-proc-processtable \
perl-sereal-decoder \
perl-sereal-encoder \
perl-socket6 \
perl-sub-identify \
perl-variable-magic \
perl-xml-libxml \
opendkim \
opendkim-utils \
opendmarc \
postfix \
postfix-pcre \
rspamd \
postfix-policyd-spf-perl \
spamassassin \
spamassassin-client \
supervisor \
@ -129,8 +51,9 @@ RUN echo "@edge https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/ap
tzdata \
&& rm -rf /var/cache/apk/*
# Copy Mail::Milter::Authentication and its dependancies
COPY --from=pl /usr/local/ /usr/local/
# Get test-only version of postfix-policyd-spf-perl
ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl
RUN chmod +x /usr/bin/postfix-policyd-spf-perl && chmod 755 /usr/bin/postfix-policyd-spf-perl
# Create happydeliver user and group
RUN addgroup -g 1000 happydeliver && \
@ -140,15 +63,12 @@ RUN addgroup -g 1000 happydeliver && \
RUN mkdir -p /etc/happydeliver \
/var/lib/happydeliver \
/var/log/happydeliver \
/var/cache/authentication_milter \
/var/lib/authentication_milter \
/var/spool/postfix/authentication_milter \
/var/spool/postfix/spamassassin \
/var/spool/postfix/rspamd \
/var/spool/postfix/opendkim \
/var/spool/postfix/opendmarc \
/etc/opendkim/keys \
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
&& chown -R mail:mail /var/spool/postfix/authentication_milter /var/spool/postfix/spamassassin \
&& chown rspamd:mail /var/spool/postfix/rspamd \
&& chmod 750 /var/spool/postfix/rspamd
&& chown -R opendkim:postfix /var/spool/postfix/opendkim \
&& chown -R opendmarc:postfix /var/spool/postfix/opendmarc
# Copy the built application
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
@ -156,9 +76,9 @@ RUN chmod +x /usr/local/bin/happyDeliver
# Copy configuration files
COPY docker/postfix/ /etc/postfix/
COPY docker/authentication_milter/authentication_milter.json /etc/authentication_milter.json
COPY docker/opendkim/ /etc/opendkim/
COPY docker/opendmarc/ /etc/opendmarc/
COPY docker/spamassassin/ /etc/mail/spamassassin/
COPY docker/rspamd/local.d/ /etc/rspamd/local.d/
COPY docker/supervisor/ /etc/supervisor/
COPY docker/entrypoint.sh /entrypoint.sh
@ -170,21 +90,11 @@ RUN chmod +x /entrypoint.sh
EXPOSE 25 8080
# Default configuration
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite \
HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db \
HAPPYDELIVER_DOMAIN=happydeliver.local \
HAPPYDELIVER_ADDRESS_PREFIX=test- \
HAPPYDELIVER_DNS_TIMEOUT=5s \
HAPPYDELIVER_HTTP_TIMEOUT=10s \
HAPPYDELIVER_RSPAMD_API_URL=http://127.0.0.1:11334
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net
# Volume for persistent data
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8080/api/status || exit 1
# Set entrypoint
ENTRYPOINT ["/entrypoint.sh"]
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]

166
README.md
View file

@ -6,27 +6,25 @@ An open-source email deliverability testing platform that analyzes test emails a
## Features
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin and rspamd scores, DNS records, blacklist status, content quality, and more
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, SpamAssassin scores, DNS records, blacklist status, content quality, and more
- **REST API**: Full-featured API for creating tests and retrieving reports
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers
- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers
- **Database Storage**: SQLite or PostgreSQL support
- **Configurable**: via environment or config file for all settings
![A sample deliverability report](web/static/img/report.webp)
## Quick Start
### With Docker (Recommended)
The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, authentication_milter, SpamAssassin, and the happyDeliver application.
The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application.
#### What's included in the Docker container:
- **Postfix MTA**: Receives emails on port 25
- **authentication_milter**: Entreprise grade email authentication
- **OpenDKIM**: DKIM signature verification
- **OpenDMARC**: DMARC policy validation
- **SpamAssassin**: Spam scoring and analysis
- **rspamd**: Second spam filter for cross-validated scoring
- **happyDeliver API**: REST API server on port 8080
- **SQLite Database**: Persistent storage for tests and reports
@ -38,7 +36,7 @@ git clone https://git.nemunai.re/happyDomain/happyDeliver.git
cd happydeliver
# Edit docker-compose.yml to set your domain
# Change HAPPYDELIVER_DOMAIN environment variable and hostname
# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables
# Build and start
docker-compose up -d
@ -64,86 +62,12 @@ docker run -d \
-p 25:25 \
-p 8080:8080 \
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
--hostname mail.yourdomain.com \
-e HOSTNAME=mail.yourdomain.com \
-v $(pwd)/data:/var/lib/happydeliver \
-v $(pwd)/logs:/var/log/happydeliver \
happydeliver:latest
```
#### 3. Configure TLS Certificates (Optional but Recommended)
To enable TLS encryption for incoming SMTP connections, you can configure Postfix to use your SSL/TLS certificates. This is highly recommended for production deployments.
##### Using docker-compose
Add the certificate paths to your `docker-compose.yml`:
```yaml
environment:
- POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt
- POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key
volumes:
- /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro
- /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro
```
##### Using docker run
```bash
docker run -d \
--name happydeliver \
-p 25:25 \
-p 8080:8080 \
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
-e POSTFIX_CERT_FILE=/etc/ssl/certs/mail.yourdomain.com.crt \
-e POSTFIX_KEY_FILE=/etc/ssl/private/mail.yourdomain.com.key \
--hostname mail.yourdomain.com \
-v /path/to/your/certificate.crt:/etc/ssl/certs/mail.yourdomain.com.crt:ro \
-v /path/to/your/private.key:/etc/ssl/private/mail.yourdomain.com.key:ro \
-v $(pwd)/data:/var/lib/happydeliver \
-v $(pwd)/logs:/var/log/happydeliver \
happydeliver:latest
```
**Notes:**
- The certificate file should contain the full certificate chain (certificate + intermediate CAs)
- The private key file must be readable by the postfix user inside the container
- TLS is configured with `smtpd_tls_security_level = may`, which means it's opportunistic (STARTTLS supported but not required)
- If both environment variables are not set, Postfix will run without TLS support
#### 4. Configure Network and DNS
##### Open SMTP Port
Port 25 (SMTP) must be accessible from the internet to receive test emails:
```bash
# Check if port 25 is listening
netstat -ln | grep :25
# Allow port 25 through firewall (example with ufw)
sudo ufw allow 25/tcp
# For iptables
sudo iptables -A INPUT -p tcp --dport 25 -j ACCEPT
```
**Note:** Many ISPs and cloud providers block port 25 by default to prevent spam. You may need to request port 25 to be unblocked through your provider's support.
##### Configure DNS Records
Point your domain to the server's IP address.
```
yourdomain.com. IN A 203.0.113.10
yourdomain.com. IN AAAA 2001:db8::10
```
Replace `yourdomain.com` with the value you set for `HAPPYDELIVER_DOMAIN` and IPs accordingly.
There is no need for an MX record here since the same host will serve both HTTP and SMTP.
### Manual Build
#### 1. Build
@ -163,27 +87,10 @@ The server will start on `http://localhost:8080` by default.
#### 3. Integrate with your existing e-mail setup
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, ...
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
#### Receiver Hostname
happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`).
If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly:
```bash
./happyDeliver server -receiver-hostname mail.example.com
```
Or via environment variable:
```bash
HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server
```
**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`.
If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
Choose one of the following way to integrate happyDeliver in your existing setup:
#### Postfix LMTP Transport
@ -201,9 +108,9 @@ You'll obtain the best results with a custom [transport rule](https://www.postfi
```
# Transport map - route test emails to happyDeliver LMTP server
# Pattern: test-<base32-uuid>@yourdomain.com -> LMTP on localhost:2525
# Pattern: test-<uuid>@yourdomain.com -> LMTP on localhost:2525
/^test-[a-zA-Z2-7-]{26,30}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
```
3. Append the created file to `transport_maps` in your `main.cf`:
@ -237,7 +144,7 @@ Response:
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost",
"email": "test-550e8400@localhost",
"status": "pending",
"message": "Send your test email to the address above"
}
@ -279,43 +186,24 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
**Note:** In production, emails are delivered via LMTP (see integration instructions above).
## Use with happyDomain
happyDeliver can be driven by [happyDomain](https://happydomain.org) through
the [`checker-happydeliver`](https://git.nemunai.re/happyDomain/checker-happydeliver)
plugin, so the deliverability of a domain you manage is monitored alongside
its DNS and inbound SMTP posture.
How it works:
1. Attach the **Outbound deliverability** checker to the mail service of a zone
in happyDomain. Point it at a happyDeliver instance via `happydeliver_url`;
operators can configure a default instance globally.
2. On each run, the checker calls `POST /api/test` to allocate a fresh
recipient address, prompts the user (or an automated sender) to mail it from
the tested domain, then polls `GET /api/test/{id}` until the report is
ready.
3. The structured report from `GET /api/report/{id}` is translated into
happyDomain rule states: CRIT/WARN/INFO on SPF, DKIM, DMARC, alignment, spam
score, blacklists and headers, plus an overall score threshold
(`min_score`/`warn_score`).
4. Runs repeat on a configurable interval so a regression in deliverability (a
new RBL listing, a DKIM key rotation gone wrong, a broken SPF include, ...)
surfaces as a domain-level alert in happyDomain.
See the [`checker-happydeliver` repository](https://git.nemunai.re/happyDomain/checker-happydeliver)
for build instructions and the full list of run options.
## Scoring System
The deliverability score is calculated from A to F based on:
The deliverability score is calculated from 0 to 10 based on:
- **DNS**: Step-by-step analysis of PTR, Forward-Confirmed Reverse DNS, MX, SPF, DKIM, DMARC and BIMI records
- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
- **Blacklist**: RBL/DNSBL checks
- **Headers**: Required headers, MIME structure, Domain alignment
- **Spam**: SpamAssassin and rspamd scores (combined 50/50)
- **Content**: HTML quality, links, images, unsubscribe
- **Authentication (3 pts)**: SPF, DKIM, DMARC validation
- **Spam (2 pts)**: SpamAssassin score
- **Blacklist (2 pts)**: RBL/DNSBL checks
- **Content (2 pts)**: HTML quality, links, images, unsubscribe
- **Headers (1 pt)**: Required headers, MIME structure
**Note:** BIMI (Brand Indicators for Message Identification) is also checked and reported but does not contribute to the score, as it's a branding feature rather than a deliverability factor.
**Ratings:**
- 9-10: Excellent
- 7-8.9: Good
- 5-6.9: Fair
- 3-4.9: Poor
- 0-2.9: Critical
## Funding

View file

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

View file

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

View file

@ -52,7 +52,7 @@ paths:
tags:
- tests
summary: Get test status
description: Check if a report exists for the given test ID (base32-encoded). Returns pending if no report exists, analyzed if a report is available.
description: Check if a report exists for the given test ID. Returns pending if no report exists, analyzed if a report is available.
operationId: getTest
parameters:
- name: id
@ -60,8 +60,7 @@ paths:
required: true
schema:
type: string
pattern: '^[a-z0-9-]+$'
description: Base32-encoded test ID (with hyphens)
format: uuid
responses:
'200':
description: Test status retrieved successfully
@ -76,49 +75,6 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/tests:
get:
tags:
- tests
summary: List all tests
description: Returns a paginated list of test summaries with scores and grades. Can be disabled via server configuration.
operationId: listTests
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
default: 0
description: Number of items to skip
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: Maximum number of items to return
responses:
'200':
description: List of test summaries
content:
application/json:
schema:
$ref: '#/components/schemas/TestListResponse'
'403':
description: Test listing is disabled
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/report/{id}:
get:
tags:
@ -132,8 +88,7 @@ paths:
required: true
schema:
type: string
pattern: '^[a-z0-9-]+$'
description: Base32-encoded test ID (with hyphens)
format: uuid
responses:
'200':
description: Report retrieved successfully
@ -161,8 +116,7 @@ paths:
required: true
schema:
type: string
pattern: '^[a-z0-9-]+$'
description: Base32-encoded test ID (with hyphens)
format: uuid
responses:
'200':
description: Raw email retrieved successfully
@ -177,107 +131,6 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/report/{id}/reanalyze:
post:
tags:
- reports
summary: Reanalyze email and regenerate report
description: Re-run the analysis on the stored raw email to regenerate the report with the latest analyzer version. This is useful after analyzer improvements or bug fixes.
operationId: reanalyzeReport
parameters:
- name: id
in: path
required: true
schema:
type: string
pattern: '^[a-z0-9-]+$'
description: Base32-encoded test ID (with hyphens)
responses:
'200':
description: Report regenerated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Report'
'404':
description: Email not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error during reanalysis
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/domain:
post:
tags:
- tests
summary: Test a domain's email configuration
description: Analyzes DNS records (MX, SPF, DMARC, BIMI) for a domain without requiring an actual email to be sent. Returns results immediately.
operationId: testDomain
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DomainTestRequest'
responses:
'200':
description: Domain test completed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/DomainTestResponse'
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/blacklist:
post:
tags:
- tests
summary: Check an IP address against DNS blacklists
description: Tests a single IP address (IPv4 or IPv6) against configured DNS-based blacklists (RBLs) without requiring an actual email to be sent. Returns results immediately.
operationId: checkBlacklist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BlacklistCheckRequest'
responses:
'200':
description: Blacklist check completed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/BlacklistCheckResponse'
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/status:
get:
tags:
@ -296,74 +149,386 @@ paths:
components:
schemas:
Test:
$ref: './schemas.yaml#/components/schemas/Test'
type: object
required:
- id
- email
- status
- created_at
properties:
id:
type: string
format: uuid
description: Unique test identifier
example: "550e8400-e29b-41d4-a716-446655440000"
email:
type: string
format: email
description: Unique test email address
example: "test-550e8400@example.com"
status:
type: string
enum: [pending, analyzed]
description: Current test status (pending = no report yet, analyzed = report available)
example: "analyzed"
created_at:
type: string
format: date-time
description: Test creation timestamp
updated_at:
type: string
format: date-time
description: Last update timestamp
TestResponse:
$ref: './schemas.yaml#/components/schemas/TestResponse'
type: object
required:
- id
- email
- status
properties:
id:
type: string
format: uuid
example: "550e8400-e29b-41d4-a716-446655440000"
email:
type: string
format: email
example: "test-550e8400@example.com"
status:
type: string
enum: [pending]
example: "pending"
message:
type: string
example: "Send your test email to the address above"
Report:
$ref: './schemas.yaml#/components/schemas/Report'
type: object
required:
- id
- test_id
- score
- checks
- created_at
properties:
id:
type: string
format: uuid
description: Report identifier
test_id:
type: string
format: uuid
description: Associated test ID
score:
type: number
format: float
minimum: 0
maximum: 10
description: Overall deliverability score (0-10)
example: 8.5
summary:
$ref: '#/components/schemas/ScoreSummary'
checks:
type: array
items:
$ref: '#/components/schemas/Check'
authentication:
$ref: '#/components/schemas/AuthenticationResults'
spamassassin:
$ref: '#/components/schemas/SpamAssassinResult'
dns_records:
type: array
items:
$ref: '#/components/schemas/DNSRecord'
blacklists:
type: array
items:
$ref: '#/components/schemas/BlacklistCheck'
raw_headers:
type: string
description: Raw email headers
created_at:
type: string
format: date-time
ScoreSummary:
$ref: './schemas.yaml#/components/schemas/ScoreSummary'
ContentAnalysis:
$ref: './schemas.yaml#/components/schemas/ContentAnalysis'
ContentIssue:
$ref: './schemas.yaml#/components/schemas/ContentIssue'
LinkCheck:
$ref: './schemas.yaml#/components/schemas/LinkCheck'
ImageCheck:
$ref: './schemas.yaml#/components/schemas/ImageCheck'
HeaderAnalysis:
$ref: './schemas.yaml#/components/schemas/HeaderAnalysis'
HeaderCheck:
$ref: './schemas.yaml#/components/schemas/HeaderCheck'
ReceivedHop:
$ref: './schemas.yaml#/components/schemas/ReceivedHop'
DKIMDomainInfo:
$ref: './schemas.yaml#/components/schemas/DKIMDomainInfo'
DomainAlignment:
$ref: './schemas.yaml#/components/schemas/DomainAlignment'
HeaderIssue:
$ref: './schemas.yaml#/components/schemas/HeaderIssue'
type: object
required:
- authentication_score
- spam_score
- blacklist_score
- content_score
- header_score
properties:
authentication_score:
type: number
format: float
minimum: 0
maximum: 3
description: SPF/DKIM/DMARC score (max 3 pts)
example: 2.8
spam_score:
type: number
format: float
minimum: 0
maximum: 2
description: SpamAssassin score (max 2 pts)
example: 1.5
blacklist_score:
type: number
format: float
minimum: 0
maximum: 2
description: Blacklist check score (max 2 pts)
example: 2.0
content_score:
type: number
format: float
minimum: 0
maximum: 2
description: Content quality score (max 2 pts)
example: 1.8
header_score:
type: number
format: float
minimum: 0
maximum: 1
description: Header quality score (max 1 pt)
example: 0.9
Check:
type: object
required:
- category
- name
- status
- score
- message
properties:
category:
type: string
enum: [authentication, dns, content, blacklist, headers, spam]
description: Check category
example: "authentication"
name:
type: string
description: Check name
example: "DKIM Signature"
status:
type: string
enum: [pass, fail, warn, info, error]
description: Check result status
example: "pass"
score:
type: number
format: float
description: Points contributed to total score
example: 1.0
message:
type: string
description: Human-readable result message
example: "DKIM signature is valid"
details:
type: string
description: Additional details (may be JSON)
severity:
type: string
enum: [critical, high, medium, low, info]
description: Issue severity
example: "info"
advice:
type: string
description: Remediation advice
example: "Your DKIM configuration is correct"
AuthenticationResults:
$ref: './schemas.yaml#/components/schemas/AuthenticationResults'
type: object
properties:
spf:
$ref: '#/components/schemas/AuthResult'
dkim:
type: array
items:
$ref: '#/components/schemas/AuthResult'
dmarc:
$ref: '#/components/schemas/AuthResult'
bimi:
$ref: '#/components/schemas/AuthResult'
arc:
$ref: '#/components/schemas/ARCResult'
AuthResult:
$ref: './schemas.yaml#/components/schemas/AuthResult'
type: object
required:
- result
properties:
result:
type: string
enum: [pass, fail, none, neutral, softfail, temperror, permerror]
description: Authentication result
example: "pass"
domain:
type: string
description: Domain being authenticated
example: "example.com"
selector:
type: string
description: DKIM selector (for DKIM only)
example: "default"
details:
type: string
description: Additional details about the result
ARCResult:
$ref: './schemas.yaml#/components/schemas/ARCResult'
IPRevResult:
$ref: './schemas.yaml#/components/schemas/IPRevResult'
type: object
required:
- result
properties:
result:
type: string
enum: [pass, fail, none]
description: Overall ARC chain validation result
example: "pass"
chain_valid:
type: boolean
description: Whether the ARC chain signatures are valid
example: true
chain_length:
type: integer
description: Number of ARC sets in the chain
example: 2
details:
type: string
description: Additional details about ARC validation
example: "ARC chain valid with 2 intermediaries"
SpamAssassinResult:
$ref: './schemas.yaml#/components/schemas/SpamAssassinResult'
SpamTestDetail:
$ref: './schemas.yaml#/components/schemas/SpamTestDetail'
RspamdResult:
$ref: './schemas.yaml#/components/schemas/RspamdResult'
DNSResults:
$ref: './schemas.yaml#/components/schemas/DNSResults'
MXRecord:
$ref: './schemas.yaml#/components/schemas/MXRecord'
SPFRecord:
$ref: './schemas.yaml#/components/schemas/SPFRecord'
DKIMRecord:
$ref: './schemas.yaml#/components/schemas/DKIMRecord'
DMARCRecord:
$ref: './schemas.yaml#/components/schemas/DMARCRecord'
BIMIRecord:
$ref: './schemas.yaml#/components/schemas/BIMIRecord'
type: object
required:
- score
- required_score
- is_spam
properties:
score:
type: number
format: float
description: SpamAssassin spam score
example: 2.3
required_score:
type: number
format: float
description: Threshold for spam classification
example: 5.0
is_spam:
type: boolean
description: Whether message is classified as spam
example: false
tests:
type: array
items:
type: string
description: List of triggered SpamAssassin tests
example: ["BAYES_00", "DKIM_SIGNED"]
report:
type: string
description: Full SpamAssassin report
DNSRecord:
type: object
required:
- domain
- record_type
- status
properties:
domain:
type: string
description: Domain name
example: "example.com"
record_type:
type: string
enum: [MX, SPF, DKIM, DMARC, BIMI]
description: DNS record type
example: "SPF"
status:
type: string
enum: [found, missing, invalid]
description: Record status
example: "found"
value:
type: string
description: Record value
example: "v=spf1 include:_spf.example.com ~all"
BlacklistCheck:
$ref: './schemas.yaml#/components/schemas/BlacklistCheck'
type: object
required:
- ip
- rbl
- listed
properties:
ip:
type: string
description: IP address checked
example: "192.0.2.1"
rbl:
type: string
description: RBL/DNSBL name
example: "zen.spamhaus.org"
listed:
type: boolean
description: Whether IP is listed
example: false
response:
type: string
description: RBL response code or message
example: "127.0.0.2"
Status:
$ref: './schemas.yaml#/components/schemas/Status'
type: object
required:
- status
- version
properties:
status:
type: string
enum: [healthy, degraded, unhealthy]
description: Overall service status
example: "healthy"
version:
type: string
description: Service version
example: "0.1.0-dev"
components:
type: object
properties:
database:
type: string
enum: [up, down]
example: "up"
mta:
type: string
enum: [up, down]
example: "up"
uptime:
type: integer
description: Service uptime in seconds
example: 3600
Error:
$ref: './schemas.yaml#/components/schemas/Error'
DomainTestRequest:
$ref: './schemas.yaml#/components/schemas/DomainTestRequest'
DomainTestResponse:
$ref: './schemas.yaml#/components/schemas/DomainTestResponse'
BlacklistCheckRequest:
$ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest'
BlacklistCheckResponse:
$ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse'
TestSummary:
$ref: './schemas.yaml#/components/schemas/TestSummary'
TestListResponse:
$ref: './schemas.yaml#/components/schemas/TestListResponse'
type: object
required:
- error
- message
properties:
error:
type: string
description: Error code
example: "not_found"
message:
type: string
description: Human-readable error message
example: "Test not found"
details:
type: string
description: Additional error details

File diff suppressed because it is too large Load diff

View file

@ -29,12 +29,13 @@ import (
"git.happydns.org/happyDeliver/internal/app"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/version"
)
const version = "0.1.0-dev"
func main() {
fmt.Fprintln(os.Stderr, "happyDeliver - Email Deliverability Testing Platform")
fmt.Fprintf(os.Stderr, "Version: %s\n", version.Version)
fmt.Println("happyDeliver - Email Deliverability Testing Platform")
fmt.Printf("Version: %s\n", version)
cfg, err := config.ConsolidateConfig()
if err != nil {
@ -52,20 +53,8 @@ func main() {
if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil {
log.Fatalf("Analyzer error: %v", err)
}
case "backup":
if err := app.RunBackup(cfg); err != nil {
log.Fatalf("Backup error: %v", err)
}
case "restore":
inputFile := ""
if len(flag.Args()) >= 2 {
inputFile = flag.Args()[1]
}
if err := app.RunRestore(cfg, inputFile); err != nil {
log.Fatalf("Restore error: %v", err)
}
case "version":
fmt.Println(version.Version)
fmt.Println(version)
default:
fmt.Printf("Unknown command: %s\n", command)
printUsage()
@ -75,11 +64,9 @@ func main() {
func printUsage() {
fmt.Println("\nCommand availables:")
fmt.Println(" happyDeliver server - Start the API server")
fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
fmt.Println(" happyDeliver backup - Backup database to stdout as JSON")
fmt.Println(" happyDeliver restore [file] - Restore database from JSON file or stdin")
fmt.Println(" happyDeliver version - Print version information")
fmt.Println(" happyDeliver server - Start the API server")
fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
fmt.Println(" happyDeliver version - Print version information")
fmt.Println("")
flag.Usage()
}

View file

@ -3,14 +3,14 @@ services:
build:
context: .
dockerfile: Dockerfile
image: happydomain/happydeliver:latest
image: happydeliver:latest
container_name: happydeliver
# Set a hostname
hostname: mail.happydeliver.local
environment:
# Set your domain
HAPPYDELIVER_DOMAIN: happydeliver.local
# Set your domain and hostname
DOMAIN: happydeliver.local
HOSTNAME: mail.happydeliver.local
ports:
# SMTP port
@ -26,6 +26,13 @@ services:
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
data:
logs:

View file

@ -109,37 +109,12 @@ Default configuration for the Docker environment:
The container accepts these environment variables:
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below)
- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP
### Receiver Hostname
happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`).
In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically.
**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname:
- `DOMAIN`: Email domain for test addresses (default: happydeliver.local)
- `HOSTNAME`: Container hostname (default: mail.happydeliver.local)
Example:
```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
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 ...
docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ...
```
## Volumes

View file

@ -1,75 +0,0 @@
{
"logtoerr" : "1",
"error_log" : "",
"connection" : "unix:/var/spool/postfix/authentication_milter/authentication_milter.sock",
"umask" : "0007",
"runas" : "mail",
"rungroup" : "mail",
"authserv_id" : "__HOSTNAME__",
"connect_timeout" : 30,
"command_timeout" : 30,
"content_timeout" : 300,
"dns_timeout" : 10,
"dns_retry" : 2,
"handlers" : {
"Sanitize" : {
"hosts_to_remove" : [
"__HOSTNAME__"
],
"extra_auth_results_types" : [
"X-Spam-Status",
"X-Spam-Report",
"X-Spam-Level",
"X-Spam-Checker-Version"
]
},
"SPF" : {
"hide_none" : 0
},
"DKIM" : {
"hide_none" : 0,
},
"XGoogleDKIM" : {
"hide_none" : 1,
},
"ARC" : {
"hide_none" : 0,
},
"DMARC" : {
"hide_none" : 0,
"detect_list_id" : "1"
},
"BIMI" : {},
"PTR" : {},
"SenderID" : {
"hide_none" : 1
},
"IPRev" : {},
"Auth" : {},
"AlignedFrom" : {},
"LocalIP" : {},
"TrustedIP" : {
"trusted_ip_list" : []
},
"!AddID" : {},
"ReturnOK" : {}
}
}

View file

@ -1,58 +0,0 @@
; This is YOU. DMARC reports include information about the reports. Enter it here.
[organization]
domain = example.com
org_name = My Company Limited
email = admin@example.com
extra_contact_info = http://example.com
; aggregate DMARC reports need to be stored somewhere. Any database
; with a DBI module (MySQL, SQLite, DBD, etc.) should work.
; SQLite and MySQL are tested.
; Default is sqlite.
[report_store]
backend = SQL
;dsn = dbi:SQLite:dbname=dmarc_reports.sqlite
dsn = dbi:mysql:database=dmarc_reporting_database;host=localhost;port=3306
user = authmilterusername
pass = authmiltpassword
; backend can be perl or libopendmarc
[dmarc]
backend = perl
[dns]
timeout = 5
public_suffix_list = share/public_suffix_list
[smtp]
; hostname is the external FQDN of this MTA
hostname = mx1.example.com
cc = dmarc.copy@example.com
; list IP addresses to whitelist (bypass DMARC reject/quarantine)
; see sample whitelist in share/dmarc_whitelist
whitelist = /path/to/etc/dmarc_whitelist
; By default, we attempt to email directly to the report recipient.
; Set these to relay via a SMTP smart host.
smarthost = mx2.example.com
smartuser = dmarccopyusername
smartpass = dmarccopypassword
[imap]
server = mail.example.com
user =
pass =
; the imap folder where new dmarc messages will be found
folder = dmarc
; the folders to store processed reports (a=aggregate, f=forensic)
f_done = dmarc.forensic
a_done = dmarc.aggregate
[http]
port = 8080
[https]
port = 8443
ssl_crt =
ssl_key =

View file

@ -4,42 +4,34 @@ set -e
echo "Starting happyDeliver container..."
# Get environment variables with defaults
[ -n "${HOSTNAME}" ] || HOSTNAME=$(hostname)
HOSTNAME="${HOSTNAME:-mail.happydeliver.local}"
HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}"
echo "Hostname: $HOSTNAME"
echo "Domain: $HAPPYDELIVER_DOMAIN"
# Create socket directories
mkdir -p /var/spool/postfix/authentication_milter
chown mail:mail /var/spool/postfix/authentication_milter
chmod 750 /var/spool/postfix/authentication_milter
# Create runtime directories
mkdir -p /var/run/opendkim /var/run/opendmarc
chown opendkim:postfix /var/run/opendkim
chown opendmarc:postfix /var/run/opendmarc
mkdir -p /var/spool/postfix/rspamd
chown rspamd:mail /var/spool/postfix/rspamd
chmod 750 /var/spool/postfix/rspamd
# Create socket directories
mkdir -p /var/spool/postfix/opendkim /var/spool/postfix/opendmarc
chown opendkim:postfix /var/spool/postfix/opendkim
chown opendmarc:postfix /var/spool/postfix/opendmarc
chmod 750 /var/spool/postfix/opendkim /var/spool/postfix/opendmarc
# Create log directory
mkdir -p /var/log/happydeliver /var/cache/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter /run/authentication_milter
mkdir -p /var/log/happydeliver
chown happydeliver:happydeliver /var/log/happydeliver
chown mail:mail /var/cache/authentication_milter /run/authentication_milter /var/spool/authentication_milter /var/lib/authentication_milter
# Replace placeholders in Postfix configuration
echo "Configuring Postfix..."
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/postfix/main.cf
sed -i "s/__DOMAIN__/${HAPPYDELIVER_DOMAIN}/g" /etc/postfix/main.cf
# Add certificates to postfix
[ -n "${POSTFIX_CERT_FILE}" ] && [ -n "${POSTFIX_KEY_FILE}" ] && {
cat <<EOF >> /etc/postfix/main.cf
smtpd_tls_cert_file = ${POSTFIX_CERT_FILE}
smtpd_tls_key_file = ${POSTFIX_KEY_FILE}
smtpd_tls_security_level = may
EOF
}
# Replace placeholders in configurations
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/authentication_milter.json
# Replace placeholders in OpenDMARC configuration
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/opendmarc/opendmarc.conf
# Initialize Postfix aliases
if [ -f /etc/postfix/aliases ]; then

View file

@ -0,0 +1,39 @@
# OpenDKIM configuration for happyDeliver
# Verifies DKIM signatures on incoming emails
# Log to syslog
Syslog yes
SyslogSuccess yes
LogWhy yes
# Run as this user and group
UserID opendkim:mail
UMask 002
# Socket for Postfix communication
Socket unix:/var/spool/postfix/opendkim/opendkim.sock
# Process ID file
PidFile /var/run/opendkim/opendkim.pid
# Operating mode - verify only (not signing)
Mode v
# Canonicalization methods
Canonicalization relaxed/simple
# DNS timeout
DNSTimeout 5
# Add header for verification results
AlwaysAddARHeader yes
# Accept unsigned mail
On-NoSignature accept
# Always add Authentication-Results header
AlwaysAddARHeader yes
# Maximum verification attempts
MaximumSignaturesToVerify 3

View file

@ -0,0 +1,41 @@
# OpenDMARC configuration for happyDeliver
# Verifies DMARC policies on incoming emails
# Socket for Postfix communication
Socket unix:/var/spool/postfix/opendmarc/opendmarc.sock
# Process ID file
PidFile /var/run/opendmarc/opendmarc.pid
# Run as this user and group
UserID opendmarc:mail
UMask 002
# Syslog configuration
Syslog true
SyslogFacility mail
# Ignore authentication results from other hosts
IgnoreAuthenticatedClients true
# Accept mail even if DMARC fails (we're analyzing, not filtering)
RejectFailures false
# Trust Authentication-Results headers from localhost only
TrustedAuthservIDs __HOSTNAME__
# Add DMARC results to Authentication-Results header
#AddAuthenticationResults true
# DNS timeout
DNSTimeout 5
# History file (for reporting)
# HistoryFile /var/spool/opendmarc/opendmarc.dat
# Ignore hosts file
# IgnoreHosts /etc/opendmarc/ignore.hosts
# Public suffix list
# PublicSuffixList /usr/share/publicsuffix/public_suffix_list.dat

View file

@ -10,7 +10,7 @@ inet_interfaces = all
inet_protocols = ipv4
# Recipient settings
mydestination = localhost.$mydomain, localhost
mydestination = $myhostname, localhost.$mydomain, localhost
mynetworks = 127.0.0.0/8 [::1]/128
# Relay settings - accept mail for our test domain
@ -28,13 +28,14 @@ transport_maps = pcre:/etc/postfix/transport_maps
# OpenDKIM for DKIM verification
milter_default_action = accept
milter_protocol = 6
smtpd_milters = unix:/var/spool/postfix/authentication_milter/authentication_milter.sock unix:/var/spool/postfix/spamassassin/spamass-milter.sock unix:/var/spool/postfix/rspamd/rspamd-milter.sock
smtpd_milters = unix:/var/spool/postfix/opendkim/opendkim.sock, unix:/var/spool/postfix/opendmarc/opendmarc.sock
non_smtpd_milters = $smtpd_milters
# SPF policy checking
smtpd_recipient_restrictions =
permit_mynetworks,
reject_unauth_destination
reject_unauth_destination,
check_policy_service unix:private/policy-spf
# Logging
debug_peer_level = 2

View file

@ -2,6 +2,7 @@
# SMTP service
smtp inet n - n - - smtpd
-o content_filter=spamassassin
# Pickup service
pickup unix n - n 60 1 pickup
@ -73,6 +74,10 @@ scache unix - - n - 1 scache
maildrop unix - n n - - pipe
flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
# SPF policy service
policy-spf unix - n n - 0 spawn
user=nobody argv=/usr/bin/postfix-policyd-spf-perl
# SpamAssassin content filter
spamassassin unix - n n - - pipe
user=mail argv=/usr/bin/spamc -f -e /usr/sbin/sendmail -oi -f ${sender} ${recipient}

View file

@ -1,4 +1,4 @@
# Transport map - route test emails to happyDeliver LMTP server
# Pattern: test-<base32-uuid>@domain.com -> LMTP on localhost:2525
# Pattern: test-<uuid>@domain.com -> LMTP on localhost:2525
/^test-[a-zA-Z2-7-]{26,30}@.*$/ lmtp:inet:127.0.0.1:2525
/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525

View file

@ -1,5 +0,0 @@
no_action = 0;
reject = null;
add_header = null;
rewrite_subject = null;
greylist = null;

View file

@ -1,5 +0,0 @@
# Add "extended Rspamd headers"
extended_spam_headers = true;
skip_local = false;
skip_authenticated = false;

View file

@ -1,3 +0,0 @@
# rspamd options for happyDeliver
# Disable Bayes learning to keep the setup stateless
use_redis = false;

View file

@ -1,6 +0,0 @@
# Enable rspamd milter proxy worker via Unix socket for Postfix integration
bind_socket = "/var/spool/postfix/rspamd/rspamd-milter.sock mode=0660 owner=rspamd group=mail";
upstream "local" {
default = yes;
self_scan = yes;
}

View file

@ -48,14 +48,3 @@ rbl_timeout 5
# Don't use user-specific rules
user_scores_dsn_timeout 3
user_scores_sql_override 0
# Disable Validity network rules
dns_query_restriction deny sa-trusted.bondedsender.org
dns_query_restriction deny sa-accredit.habeas.com
dns_query_restriction deny bl.score.senderscore.com
score RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0
score RCVD_IN_VALIDITY_RPBL_BLOCKED 0
score RCVD_IN_VALIDITY_SAFE_BLOCKED 0
score RCVD_IN_VALIDITY_CERTIFIED 0
score RCVD_IN_VALIDITY_RPBL 0
score RCVD_IN_VALIDITY_SAFE 0

View file

@ -22,26 +22,27 @@ autostart=true
autorestart=true
priority=9
# Authentication Milter service
[program:authentication_milter]
command=/usr/local/bin/authentication_milter --pidfile /run/authentication_milter/authentication_milter.pid
# OpenDKIM service
[program:opendkim]
command=/usr/sbin/opendkim -f -x /etc/opendkim/opendkim.conf
autostart=true
autorestart=true
priority=10
stdout_logfile=/var/log/happydeliver/authentication_milter.log
stderr_logfile=/var/log/happydeliver/authentication_milter.log
user=mail
stdout_logfile=/var/log/happydeliver/opendkim.log
stderr_logfile=/var/log/happydeliver/opendkim_error.log
user=opendkim
group=mail
# rspamd spam filter
[program:rspamd]
command=/usr/bin/rspamd -f -u rspamd -g mail
# OpenDMARC service
[program:opendmarc]
command=/usr/sbin/opendmarc -f -c /etc/opendmarc/opendmarc.conf
autostart=true
autorestart=true
priority=11
stdout_logfile=/var/log/happydeliver/rspamd.log
stderr_logfile=/var/log/happydeliver/rspamd_error.log
user=root
stdout_logfile=/var/log/happydeliver/opendmarc.log
stderr_logfile=/var/log/happydeliver/opendmarc_error.log
user=opendmarc
group=mail
# SpamAssassin daemon
[program:spamd]
@ -53,18 +54,6 @@ stdout_logfile=/var/log/happydeliver/spamd.log
stderr_logfile=/var/log/happydeliver/spamd_error.log
user=root
# SpamAssassin milter
[program:spamass_milter]
command=/usr/local/sbin/spamass-milter -p /var/spool/postfix/spamassassin/spamass-milter.sock -m
autostart=true
autorestart=true
priority=7
stdout_logfile=/var/log/happydeliver/spamass-milter.log
stderr_logfile=/var/log/happydeliver/spamass-milter_error.log
user=mail
group=mail
umask=007
# Postfix service
[program:postfix]
command=/usr/sbin/postfix start-fg

View file

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

77
go.mod
View file

@ -1,42 +1,38 @@
module git.happydns.org/happyDeliver
go 1.25.0
go 1.24.6
require (
github.com/JGLTechnologies/gin-rate-limit v1.5.8
github.com/emersion/go-smtp v0.24.0
github.com/getkin/kin-openapi v0.135.0
github.com/gin-gonic/gin v1.12.0
github.com/getkin/kin-openapi v0.132.0
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
github.com/oapi-codegen/runtime v1.3.0
golang.org/x/net v0.53.0
github.com/oapi-codegen/runtime v1.1.2
golang.org/x/net v0.46.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
gorm.io/gorm v1.31.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@ -44,37 +40,34 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.33 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 // indirect
github.com/oasdiff/yaml v0.0.9 // indirect
github.com/oasdiff/yaml3 v0.0.9 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
github.com/speakeasy-api/openapi v1.19.2 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.1 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // 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.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/woodsbury/decimal128 v1.4.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/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/crypto v0.50.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

172
go.sum
View file

@ -1,32 +1,19 @@
github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0=
github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
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.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.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/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
@ -37,35 +24,33 @@ github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4Mc
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
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/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@ -91,8 +76,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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -115,12 +100,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -131,14 +116,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.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
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.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw=
github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
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.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
@ -155,67 +140,52 @@ 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/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
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.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/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/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M=
github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU=
github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
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/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/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -223,13 +193,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -245,21 +215,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -272,8 +242,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@ -295,5 +265,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View file

@ -31,34 +31,21 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/version"
)
// EmailAnalyzer defines the interface for email analysis
// This interface breaks the circular dependency with pkg/analyzer
type EmailAnalyzer interface {
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
AnalyzeDomain(domain string) (dnsResults *model.DNSResults, score int, grade string)
CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.BlacklistCheck, listedCount int, score int, grade string, err error)
}
// APIHandler implements the ServerInterface for handling API requests
type APIHandler struct {
storage storage.Storage
config *config.Config
analyzer EmailAnalyzer
startTime time.Time
}
// NewAPIHandler creates a new API handler
func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler {
func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler {
return &APIHandler{
storage: store,
config: cfg,
analyzer: analyzer,
startTime: time.Now(),
}
}
@ -69,99 +56,79 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
// Generate a unique test ID (no database record created)
testID := uuid.New()
// Convert UUID to base32 string for the API response
base32ID := utils.UUIDToBase32(testID)
// Generate test email address using Base32-encoded UUID
// Generate test email address
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
base32ID,
testID.String(),
h.config.Email.Domain,
)
// Return response
c.JSON(http.StatusCreated, model.TestResponse{
Id: base32ID,
c.JSON(http.StatusCreated, TestResponse{
Id: testID,
Email: openapi_types.Email(email),
Status: model.TestResponseStatusPending,
Message: utils.PtrTo("Send your test email to the given address"),
Status: TestResponseStatusPending,
Message: stringPtr("Send your test email to the address above"),
})
}
// GetTest retrieves test metadata
// (GET /test/{id})
func (h *APIHandler) GetTest(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()),
})
return
}
func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
// Check if a report exists for this test ID
reportExists, err := h.storage.ReportExists(testUUID)
reportExists, err := h.storage.ReportExists(id)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to check test status",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
// Determine status based on report existence
var apiStatus model.TestStatus
var apiStatus TestStatus
if reportExists {
apiStatus = model.TestStatusAnalyzed
apiStatus = TestStatusAnalyzed
} else {
apiStatus = model.TestStatusPending
apiStatus = TestStatusPending
}
// Generate test email address using Base32-encoded UUID
// Generate test email address
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
id,
id.String(),
h.config.Email.Domain,
)
c.JSON(http.StatusOK, model.Test{
Id: id,
Email: openapi_types.Email(email),
Status: apiStatus,
// Return current time for CreatedAt/UpdatedAt since we don't track tests anymore
now := time.Now()
c.JSON(http.StatusOK, Test{
Id: id,
Email: openapi_types.Email(email),
Status: apiStatus,
CreatedAt: now,
UpdatedAt: &now,
})
}
// GetReport retrieves the detailed analysis report
// (GET /report/{id})
func (h *APIHandler) GetReport(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()),
})
return
}
reportJSON, _, err := h.storage.GetReport(testUUID)
func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) {
reportJSON, _, err := h.storage.GetReport(id)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, model.Error{
c.JSON(http.StatusNotFound, Error{
Error: "not_found",
Message: "Report not found",
})
return
}
c.JSON(http.StatusInternalServerError, model.Error{
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve report",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
@ -172,31 +139,20 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
// GetRawEmail retrieves the raw annotated email
// (GET /report/{id}/raw)
func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()),
})
return
}
_, rawEmail, err := h.storage.GetReport(testUUID)
func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) {
_, rawEmail, err := h.storage.GetReport(id)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, model.Error{
c.JSON(http.StatusNotFound, Error{
Error: "not_found",
Message: "Email not found",
})
return
}
c.JSON(http.StatusInternalServerError, model.Error{
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve raw email",
Details: utils.PtrTo(err.Error()),
Details: stringPtr(err.Error()),
})
return
}
@ -204,63 +160,6 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
c.Data(http.StatusOK, "text/plain", rawEmail)
}
// ReanalyzeReport re-analyzes an existing email and regenerates the report
// (POST /report/{id}/reanalyze)
func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: utils.PtrTo(err.Error()),
})
return
}
// Retrieve the existing report (mainly to get the raw email)
_, rawEmail, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, model.Error{
Error: "not_found",
Message: "Email not found",
})
return
}
c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to retrieve email",
Details: utils.PtrTo(err.Error()),
})
return
}
// Re-analyze the email using the current analyzer
reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{
Error: "analysis_error",
Message: "Failed to re-analyze email",
Details: utils.PtrTo(err.Error()),
})
return
}
// Update the report in storage
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to update report",
Details: utils.PtrTo(err.Error()),
})
return
}
// Return the updated report JSON directly
c.Data(http.StatusOK, "application/json", reportJSON)
}
// GetStatus retrieves service health status
// (GET /status)
func (h *APIHandler) GetStatus(c *gin.Context) {
@ -268,24 +167,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
uptime := int(time.Since(h.startTime).Seconds())
// Check database connectivity by trying to check if a report exists
dbStatus := model.StatusComponentsDatabaseUp
dbStatus := StatusComponentsDatabaseUp
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
dbStatus = model.StatusComponentsDatabaseDown
dbStatus = StatusComponentsDatabaseDown
}
// Determine overall status
overallStatus := model.Healthy
if dbStatus == model.StatusComponentsDatabaseDown {
overallStatus = model.Unhealthy
overallStatus := Healthy
if dbStatus == StatusComponentsDatabaseDown {
overallStatus = Unhealthy
}
mtaStatus := model.StatusComponentsMtaUp
c.JSON(http.StatusOK, model.Status{
mtaStatus := StatusComponentsMtaUp
c.JSON(http.StatusOK, Status{
Status: overallStatus,
Version: version.Version,
Version: "0.1.0-dev",
Components: &struct {
Database *model.StatusComponentsDatabase `json:"database,omitempty"`
Mta *model.StatusComponentsMta `json:"mta,omitempty"`
Database *StatusComponentsDatabase `json:"database,omitempty"`
Mta *StatusComponentsMta `json:"mta,omitempty"`
}{
Database: &dbStatus,
Mta: &mtaStatus,
@ -293,133 +192,3 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
Uptime: &uptime,
})
}
// TestDomain performs synchronous domain analysis
// (POST /domain)
func (h *APIHandler) TestDomain(c *gin.Context) {
var request model.DomainTestRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: utils.PtrTo(err.Error()),
})
return
}
// Perform domain analysis
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
// Convert grade string to DomainTestResponseGrade enum
var responseGrade model.DomainTestResponseGrade
switch grade {
case "A+":
responseGrade = model.DomainTestResponseGradeA
case "A":
responseGrade = model.DomainTestResponseGradeA1
case "B":
responseGrade = model.DomainTestResponseGradeB
case "C":
responseGrade = model.DomainTestResponseGradeC
case "D":
responseGrade = model.DomainTestResponseGradeD
case "E":
responseGrade = model.DomainTestResponseGradeE
case "F":
responseGrade = model.DomainTestResponseGradeF
default:
responseGrade = model.DomainTestResponseGradeF
}
// Build response
response := model.DomainTestResponse{
Domain: request.Domain,
Score: score,
Grade: responseGrade,
DnsResults: *dnsResults,
}
c.JSON(http.StatusOK, response)
}
// CheckBlacklist checks an IP address against DNS blacklists
// (POST /blacklist)
func (h *APIHandler) CheckBlacklist(c *gin.Context) {
var request model.BlacklistCheckRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: utils.PtrTo(err.Error()),
})
return
}
// Perform blacklist check using analyzer
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error{
Error: "invalid_ip",
Message: "Invalid IP address",
Details: utils.PtrTo(err.Error()),
})
return
}
// Build response
response := model.BlacklistCheckResponse{
Ip: request.Ip,
Blacklists: checks,
Whitelists: &whitelists,
ListedCount: listedCount,
Score: score,
Grade: model.BlacklistCheckResponseGrade(grade),
}
c.JSON(http.StatusOK, response)
}
// ListTests returns a paginated list of test summaries
// (GET /tests)
func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {
if h.config.DisableTestList {
c.JSON(http.StatusForbidden, model.Error{
Error: "feature_disabled",
Message: "Test listing is disabled on this instance",
})
return
}
offset := 0
limit := 20
if params.Offset != nil {
offset = *params.Offset
}
if params.Limit != nil {
limit = *params.Limit
if limit > 100 {
limit = 100
}
}
tests, total, err := h.storage.ListReportSummaries(offset, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{
Error: "internal_error",
Message: "Failed to list tests",
Details: utils.PtrTo(err.Error()),
})
return
}
c.JSON(http.StatusOK, model.TestListResponse{
Tests: tests,
Total: int(total),
Offset: offset,
Limit: limit,
})
}

View file

@ -1,5 +1,5 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2026 happyDomain
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
@ -19,7 +19,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package utils
package api
func stringPtr(s string) *string {
return &s
}
// PtrTo returns a pointer to the provided value
func PtrTo[T any](v T) *T {

View file

@ -31,6 +31,7 @@ import (
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/pkg/analyzer"
)
@ -86,549 +87,57 @@ func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error {
// outputHumanReadable outputs a human-readable summary
func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error {
report := result.Report
// Header with overall score
// Header
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT")
fmt.Fprintln(writer, strings.Repeat("=", 70))
fmt.Fprintf(writer, "\nOverall Score: %d/100 (Grade: %s)\n", report.Score, report.Grade)
fmt.Fprintf(writer, "Test ID: %s\n", report.TestId)
fmt.Fprintf(writer, "Generated: %s\n", report.CreatedAt.Format("2006-01-02 15:04:05 MST"))
// Score Summary
if report.Summary != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "SCORE BREAKDOWN")
fmt.Fprintln(writer, strings.Repeat("-", 70))
// Score summary
summary := emailAnalyzer.GetScoreSummaryText(result)
fmt.Fprintln(writer, summary)
summary := report.Summary
fmt.Fprintf(writer, " DNS Configuration: %3d%% (%s)\n",
summary.DnsScore, summary.DnsGrade)
fmt.Fprintf(writer, " Authentication: %3d%% (%s)\n",
summary.AuthenticationScore, summary.AuthenticationGrade)
fmt.Fprintf(writer, " Blacklist Status: %3d%% (%s)\n",
summary.BlacklistScore, summary.BlacklistGrade)
fmt.Fprintf(writer, " Header Quality: %3d%% (%s)\n",
summary.HeaderScore, summary.HeaderGrade)
fmt.Fprintf(writer, " Spam Score: %3d%% (%s)\n",
summary.SpamScore, summary.SpamGrade)
fmt.Fprintf(writer, " Content Quality: %3d%% (%s)\n",
summary.ContentScore, summary.ContentGrade)
// Detailed checks
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "DETAILED CHECK RESULTS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
// Group checks by category
categories := make(map[api.CheckCategory][]api.Check)
for _, check := range result.Report.Checks {
categories[check.Category] = append(categories[check.Category], check)
}
// DNS Results
if report.DnsResults != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "DNS CONFIGURATION")
fmt.Fprintln(writer, strings.Repeat("-", 70))
// Print checks by category
categoryOrder := []api.CheckCategory{
api.Authentication,
api.Dns,
api.Blacklist,
api.Content,
api.Headers,
}
dns := report.DnsResults
fmt.Fprintf(writer, "\nFrom Domain: %s\n", dns.FromDomain)
if dns.RpDomain != nil && *dns.RpDomain != dns.FromDomain {
fmt.Fprintf(writer, "Return-Path Domain: %s\n", *dns.RpDomain)
for _, category := range categoryOrder {
checks, ok := categories[category]
if !ok || len(checks) == 0 {
continue
}
// MX Records
if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 {
fmt.Fprintln(writer, "\n MX Records (From Domain):")
for _, mx := range *dns.FromMxRecords {
status := "✓"
if !mx.Valid {
status = "✗"
}
fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host)
if mx.Error != nil {
fmt.Fprintf(writer, " - ERROR: %s", *mx.Error)
}
fmt.Fprintln(writer)
fmt.Fprintf(writer, "\n%s:\n", category)
for _, check := range checks {
statusSymbol := "✓"
if check.Status == api.CheckStatusFail {
statusSymbol = "✗"
} else if check.Status == api.CheckStatusWarn {
statusSymbol = "⚠"
}
}
// SPF Records
if dns.SpfRecords != nil && len(*dns.SpfRecords) > 0 {
fmt.Fprintln(writer, "\n SPF Records:")
for _, spf := range *dns.SpfRecords {
status := "✓"
if !spf.Valid {
status = "✗"
}
fmt.Fprintf(writer, " %s ", status)
if spf.Domain != nil {
fmt.Fprintf(writer, "Domain: %s", *spf.Domain)
}
if spf.AllQualifier != nil {
fmt.Fprintf(writer, " (all: %s)", *spf.AllQualifier)
}
fmt.Fprintln(writer)
if spf.Record != nil {
fmt.Fprintf(writer, " %s\n", *spf.Record)
}
if spf.Error != nil {
fmt.Fprintf(writer, " ERROR: %s\n", *spf.Error)
}
}
}
// DKIM Records
if dns.DkimRecords != nil && len(*dns.DkimRecords) > 0 {
fmt.Fprintln(writer, "\n DKIM Records:")
for _, dkim := range *dns.DkimRecords {
status := "✓"
if !dkim.Valid {
status = "✗"
}
fmt.Fprintf(writer, " %s Selector: %s, Domain: %s\n", status, dkim.Selector, dkim.Domain)
if dkim.Record != nil {
fmt.Fprintf(writer, " %s\n", *dkim.Record)
}
if dkim.Error != nil {
fmt.Fprintf(writer, " ERROR: %s\n", *dkim.Error)
}
}
}
// DMARC Record
if dns.DmarcRecord != nil {
fmt.Fprintln(writer, "\n DMARC Record:")
status := "✓"
if !dns.DmarcRecord.Valid {
status = "✗"
}
fmt.Fprintf(writer, " %s Valid: %t", status, dns.DmarcRecord.Valid)
if dns.DmarcRecord.Policy != nil {
fmt.Fprintf(writer, ", Policy: %s", *dns.DmarcRecord.Policy)
}
if dns.DmarcRecord.SubdomainPolicy != nil {
fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy)
}
fmt.Fprintln(writer)
if dns.DmarcRecord.Record != nil {
fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record)
}
if dns.DmarcRecord.Error != nil {
fmt.Fprintf(writer, " ERROR: %s\n", *dns.DmarcRecord.Error)
}
}
// BIMI Record
if dns.BimiRecord != nil {
fmt.Fprintln(writer, "\n BIMI Record:")
status := "✓"
if !dns.BimiRecord.Valid {
status = "✗"
}
fmt.Fprintf(writer, " %s Valid: %t, Selector: %s, Domain: %s\n",
status, dns.BimiRecord.Valid, dns.BimiRecord.Selector, dns.BimiRecord.Domain)
if dns.BimiRecord.LogoUrl != nil {
fmt.Fprintf(writer, " Logo URL: %s\n", *dns.BimiRecord.LogoUrl)
}
if dns.BimiRecord.VmcUrl != nil {
fmt.Fprintf(writer, " VMC URL: %s\n", *dns.BimiRecord.VmcUrl)
}
if dns.BimiRecord.Record != nil {
fmt.Fprintf(writer, " %s\n", *dns.BimiRecord.Record)
}
if dns.BimiRecord.Error != nil {
fmt.Fprintf(writer, " ERROR: %s\n", *dns.BimiRecord.Error)
}
}
// PTR Records
if dns.PtrRecords != nil && len(*dns.PtrRecords) > 0 {
fmt.Fprintln(writer, "\n PTR (Reverse DNS) Records:")
for _, ptr := range *dns.PtrRecords {
fmt.Fprintf(writer, " %s\n", ptr)
}
}
// DNS Errors
if dns.Errors != nil && len(*dns.Errors) > 0 {
fmt.Fprintln(writer, "\n DNS Errors:")
for _, err := range *dns.Errors {
fmt.Fprintf(writer, " ! %s\n", err)
fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message)
if check.Advice != nil && *check.Advice != "" {
fmt.Fprintf(writer, " → %s\n", *check.Advice)
}
}
}
// Authentication Results
if report.Authentication != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "AUTHENTICATION RESULTS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
auth := report.Authentication
// SPF
if auth.Spf != nil {
fmt.Fprintf(writer, "\n SPF: %s", strings.ToUpper(string(auth.Spf.Result)))
if auth.Spf.Domain != nil {
fmt.Fprintf(writer, " (domain: %s)", *auth.Spf.Domain)
}
if auth.Spf.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *auth.Spf.Details)
}
fmt.Fprintln(writer)
}
// DKIM
if auth.Dkim != nil && len(*auth.Dkim) > 0 {
fmt.Fprintln(writer, "\n DKIM:")
for i, dkim := range *auth.Dkim {
fmt.Fprintf(writer, " [%d] %s", i+1, strings.ToUpper(string(dkim.Result)))
if dkim.Domain != nil {
fmt.Fprintf(writer, " (domain: %s", *dkim.Domain)
if dkim.Selector != nil {
fmt.Fprintf(writer, ", selector: %s", *dkim.Selector)
}
fmt.Fprintf(writer, ")")
}
if dkim.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *dkim.Details)
}
fmt.Fprintln(writer)
}
}
// DMARC
if auth.Dmarc != nil {
fmt.Fprintf(writer, "\n DMARC: %s", strings.ToUpper(string(auth.Dmarc.Result)))
if auth.Dmarc.Domain != nil {
fmt.Fprintf(writer, " (domain: %s)", *auth.Dmarc.Domain)
}
if auth.Dmarc.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *auth.Dmarc.Details)
}
fmt.Fprintln(writer)
}
// ARC
if auth.Arc != nil {
fmt.Fprintf(writer, "\n ARC: %s", strings.ToUpper(string(auth.Arc.Result)))
if auth.Arc.ChainLength != nil {
fmt.Fprintf(writer, " (chain length: %d)", *auth.Arc.ChainLength)
}
if auth.Arc.ChainValid != nil {
fmt.Fprintf(writer, " [valid: %t]", *auth.Arc.ChainValid)
}
if auth.Arc.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *auth.Arc.Details)
}
fmt.Fprintln(writer)
}
// BIMI
if auth.Bimi != nil {
fmt.Fprintf(writer, "\n BIMI: %s", strings.ToUpper(string(auth.Bimi.Result)))
if auth.Bimi.Domain != nil {
fmt.Fprintf(writer, " (domain: %s)", *auth.Bimi.Domain)
}
if auth.Bimi.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *auth.Bimi.Details)
}
fmt.Fprintln(writer)
}
// IP Reverse
if auth.Iprev != nil {
fmt.Fprintf(writer, "\n IP Reverse DNS: %s", strings.ToUpper(string(auth.Iprev.Result)))
if auth.Iprev.Ip != nil {
fmt.Fprintf(writer, " (ip: %s", *auth.Iprev.Ip)
if auth.Iprev.Hostname != nil {
fmt.Fprintf(writer, " -> %s", *auth.Iprev.Hostname)
}
fmt.Fprintf(writer, ")")
}
if auth.Iprev.Details != nil {
fmt.Fprintf(writer, "\n Details: %s", *auth.Iprev.Details)
}
fmt.Fprintln(writer)
}
}
// Blacklist Results
if report.Blacklists != nil && len(*report.Blacklists) > 0 {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "BLACKLIST CHECKS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
totalChecks := 0
totalListed := 0
for ip, checks := range *report.Blacklists {
totalChecks += len(checks)
fmt.Fprintf(writer, "\n IP Address: %s\n", ip)
for _, check := range checks {
status := "✓"
if check.Listed {
status = "✗"
totalListed++
}
fmt.Fprintf(writer, " %s %s", status, check.Rbl)
if check.Listed {
fmt.Fprintf(writer, " - LISTED")
if check.Response != nil {
fmt.Fprintf(writer, " (%s)", *check.Response)
}
} else {
fmt.Fprintf(writer, " - OK")
}
fmt.Fprintln(writer)
if check.Error != nil {
fmt.Fprintf(writer, " ERROR: %s\n", *check.Error)
}
}
}
fmt.Fprintf(writer, "\n Summary: %d/%d blacklists triggered\n", totalListed, totalChecks)
}
// Header Analysis
if report.HeaderAnalysis != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "HEADER ANALYSIS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
header := report.HeaderAnalysis
// Domain Alignment
if header.DomainAlignment != nil {
fmt.Fprintln(writer, "\n Domain Alignment:")
align := header.DomainAlignment
if align.FromDomain != nil {
fmt.Fprintf(writer, " From Domain: %s", *align.FromDomain)
if align.FromOrgDomain != nil {
fmt.Fprintf(writer, " (org: %s)", *align.FromOrgDomain)
}
fmt.Fprintln(writer)
}
if align.ReturnPathDomain != nil {
fmt.Fprintf(writer, " Return-Path Domain: %s", *align.ReturnPathDomain)
if align.ReturnPathOrgDomain != nil {
fmt.Fprintf(writer, " (org: %s)", *align.ReturnPathOrgDomain)
}
fmt.Fprintln(writer)
}
if align.Aligned != nil {
fmt.Fprintf(writer, " Strict Alignment: %t\n", *align.Aligned)
}
if align.RelaxedAligned != nil {
fmt.Fprintf(writer, " Relaxed Alignment: %t\n", *align.RelaxedAligned)
}
if align.Issues != nil && len(*align.Issues) > 0 {
fmt.Fprintln(writer, " Issues:")
for _, issue := range *align.Issues {
fmt.Fprintf(writer, " - %s\n", issue)
}
}
}
// Required/Important Headers
if header.Headers != nil {
fmt.Fprintln(writer, "\n Standard Headers:")
importantHeaders := []string{"from", "to", "subject", "date", "message-id", "dkim-signature"}
for _, hdrName := range importantHeaders {
if hdr, ok := (*header.Headers)[hdrName]; ok {
status := "✗"
if hdr.Present {
status = "✓"
}
fmt.Fprintf(writer, " %s %s: ", status, strings.ToUpper(hdrName))
if hdr.Present {
if hdr.Valid != nil && !*hdr.Valid {
fmt.Fprintf(writer, "INVALID")
} else {
fmt.Fprintf(writer, "OK")
}
if hdr.Importance != nil {
fmt.Fprintf(writer, " [%s]", *hdr.Importance)
}
} else {
fmt.Fprintf(writer, "MISSING")
}
fmt.Fprintln(writer)
if hdr.Issues != nil && len(*hdr.Issues) > 0 {
for _, issue := range *hdr.Issues {
fmt.Fprintf(writer, " - %s\n", issue)
}
}
}
}
}
// Header Issues
if header.Issues != nil && len(*header.Issues) > 0 {
fmt.Fprintln(writer, "\n Header Issues:")
for _, issue := range *header.Issues {
fmt.Fprintf(writer, " [%s] %s: %s\n",
strings.ToUpper(string(issue.Severity)), issue.Header, issue.Message)
if issue.Advice != nil {
fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice)
}
}
}
// Received Chain
if header.ReceivedChain != nil && len(*header.ReceivedChain) > 0 {
fmt.Fprintln(writer, "\n Email Path (Received Chain):")
for i, hop := range *header.ReceivedChain {
fmt.Fprintf(writer, " [%d] ", i+1)
if hop.From != nil {
fmt.Fprintf(writer, "%s", *hop.From)
if hop.Ip != nil {
fmt.Fprintf(writer, " (%s)", *hop.Ip)
}
}
if hop.By != nil {
fmt.Fprintf(writer, " -> %s", *hop.By)
}
fmt.Fprintln(writer)
if hop.Timestamp != nil {
fmt.Fprintf(writer, " Time: %s\n", hop.Timestamp.Format("2006-01-02 15:04:05 MST"))
}
}
}
}
// SpamAssassin Results
if report.Spamassassin != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "SPAMASSASSIN ANALYSIS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
sa := report.Spamassassin
fmt.Fprintf(writer, "\n Score: %.2f / %.2f", sa.Score, sa.RequiredScore)
if sa.IsSpam {
fmt.Fprintf(writer, " (SPAM)")
} else {
fmt.Fprintf(writer, " (HAM)")
}
fmt.Fprintln(writer)
if sa.Version != nil {
fmt.Fprintf(writer, " Version: %s\n", *sa.Version)
}
if len(sa.TestDetails) > 0 {
fmt.Fprintln(writer, "\n Triggered Tests:")
for _, test := range sa.TestDetails {
scoreStr := "+"
if test.Score < 0 {
scoreStr = ""
}
fmt.Fprintf(writer, " [%s%.2f] %s", scoreStr, test.Score, test.Name)
if test.Description != nil {
fmt.Fprintf(writer, "\n %s", *test.Description)
}
fmt.Fprintln(writer)
}
}
}
// Content Analysis
if report.ContentAnalysis != nil {
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "CONTENT ANALYSIS")
fmt.Fprintln(writer, strings.Repeat("-", 70))
content := report.ContentAnalysis
// Basic content info
fmt.Fprintln(writer, "\n Content Structure:")
if content.HasPlaintext != nil {
fmt.Fprintf(writer, " Has Plaintext: %t\n", *content.HasPlaintext)
}
if content.HasHtml != nil {
fmt.Fprintf(writer, " Has HTML: %t\n", *content.HasHtml)
}
if content.TextToImageRatio != nil {
fmt.Fprintf(writer, " Text-to-Image Ratio: %.2f\n", *content.TextToImageRatio)
}
// Unsubscribe
if content.HasUnsubscribeLink != nil {
fmt.Fprintf(writer, " Has Unsubscribe Link: %t\n", *content.HasUnsubscribeLink)
if *content.HasUnsubscribeLink && content.UnsubscribeMethods != nil && len(*content.UnsubscribeMethods) > 0 {
fmt.Fprintf(writer, " Unsubscribe Methods: ")
for i, method := range *content.UnsubscribeMethods {
if i > 0 {
fmt.Fprintf(writer, ", ")
}
fmt.Fprintf(writer, "%s", method)
}
fmt.Fprintln(writer)
}
}
// Links
if content.Links != nil && len(*content.Links) > 0 {
fmt.Fprintf(writer, "\n Links (%d total):\n", len(*content.Links))
for _, link := range *content.Links {
status := ""
switch link.Status {
case "valid":
status = "✓"
case "broken":
status = "✗"
case "suspicious":
status = "⚠"
case "redirected":
status = "→"
case "timeout":
status = "⏱"
}
fmt.Fprintf(writer, " %s [%s] %s", status, link.Status, link.Url)
if link.HttpCode != nil {
fmt.Fprintf(writer, " (HTTP %d)", *link.HttpCode)
}
fmt.Fprintln(writer)
if link.RedirectChain != nil && len(*link.RedirectChain) > 0 {
fmt.Fprintln(writer, " Redirect chain:")
for _, url := range *link.RedirectChain {
fmt.Fprintf(writer, " -> %s\n", url)
}
}
}
}
// Images
if content.Images != nil && len(*content.Images) > 0 {
fmt.Fprintf(writer, "\n Images (%d total):\n", len(*content.Images))
missingAlt := 0
trackingPixels := 0
for _, img := range *content.Images {
if !img.HasAlt {
missingAlt++
}
if img.IsTrackingPixel != nil && *img.IsTrackingPixel {
trackingPixels++
}
}
fmt.Fprintf(writer, " Images with ALT text: %d/%d\n",
len(*content.Images)-missingAlt, len(*content.Images))
if trackingPixels > 0 {
fmt.Fprintf(writer, " Tracking pixels detected: %d\n", trackingPixels)
}
}
// HTML Issues
if content.HtmlIssues != nil && len(*content.HtmlIssues) > 0 {
fmt.Fprintln(writer, "\n Content Issues:")
for _, issue := range *content.HtmlIssues {
fmt.Fprintf(writer, " [%s] %s: %s\n",
strings.ToUpper(string(issue.Severity)), issue.Type, issue.Message)
if issue.Location != nil {
fmt.Fprintf(writer, " Location: %s\n", *issue.Location)
}
if issue.Advice != nil {
fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice)
}
}
}
}
// Footer
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
fmt.Fprintf(writer, "Report generated by happyDeliver - https://happydeliver.org\n")
fmt.Fprintln(writer, strings.Repeat("=", 70))
return nil
}

View file

@ -1,156 +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 app
import (
"encoding/json"
"fmt"
"io"
"os"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/storage"
)
// BackupData represents the structure of a backup file
type BackupData struct {
Version string `json:"version"`
Reports []storage.Report `json:"reports"`
}
// RunBackup exports the database to stdout as JSON
func RunBackup(cfg *config.Config) error {
if err := cfg.Validate(); err != nil {
return err
}
// Initialize storage
store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
defer store.Close()
fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type)
// Get all reports from the database
reports, err := storage.GetAllReports(store)
if err != nil {
return fmt.Errorf("failed to retrieve reports: %w", err)
}
fmt.Fprintf(os.Stderr, "Found %d reports to backup\n", len(reports))
// Create backup data structure
backup := BackupData{
Version: "1.0",
Reports: reports,
}
// Encode to JSON and write to stdout
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(backup); err != nil {
return fmt.Errorf("failed to encode backup data: %w", err)
}
return nil
}
// RunRestore imports the database from a JSON file or stdin
func RunRestore(cfg *config.Config, inputPath string) error {
if err := cfg.Validate(); err != nil {
return err
}
// Determine input source
var reader io.Reader
if inputPath == "" || inputPath == "-" {
fmt.Fprintln(os.Stderr, "Reading backup from stdin...")
reader = os.Stdin
} else {
inFile, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("failed to open backup file: %w", err)
}
defer inFile.Close()
fmt.Fprintf(os.Stderr, "Reading backup from file: %s\n", inputPath)
reader = inFile
}
// Decode JSON
var backup BackupData
decoder := json.NewDecoder(reader)
if err := decoder.Decode(&backup); err != nil {
if err == io.EOF {
return fmt.Errorf("backup file is empty or corrupted")
}
return fmt.Errorf("failed to decode backup data: %w", err)
}
fmt.Fprintf(os.Stderr, "Backup version: %s\n", backup.Version)
fmt.Fprintf(os.Stderr, "Found %d reports in backup\n", len(backup.Reports))
// Initialize storage
store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
defer store.Close()
fmt.Fprintf(os.Stderr, "Connected to %s database\n", cfg.Database.Type)
// Restore reports
restored, skipped, failed := 0, 0, 0
for _, report := range backup.Reports {
// Check if report already exists
exists, err := store.ReportExists(report.TestID)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to check if report %s exists: %v\n", report.TestID, err)
failed++
continue
}
if exists {
fmt.Fprintf(os.Stderr, "Report %s already exists, skipping\n", report.TestID)
skipped++
continue
}
// Create the report
_, err = storage.CreateReportFromBackup(store, &report)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to restore report %s: %v\n", report.TestID, err)
failed++
continue
}
restored++
}
fmt.Fprintf(os.Stderr, "Restore completed: %d restored, %d skipped, %d failed\n", restored, skipped, failed)
if failed > 0 {
return fmt.Errorf("restore completed with %d failures", failed)
}
return nil
}

View file

@ -25,16 +25,13 @@ import (
"context"
"log"
"os"
"time"
ratelimit "github.com/JGLTechnologies/gin-rate-limit"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/lmtp"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/pkg/analyzer"
"git.happydns.org/happyDeliver/web"
)
@ -66,11 +63,8 @@ func RunServer(cfg *config.Config) error {
}
}()
// Create analyzer adapter for API
analyzerAdapter := analyzer.NewAPIAdapter(cfg)
// Create API handler
handler := api.NewAPIHandler(store, cfg, analyzerAdapter)
handler := api.NewAPIHandler(store, cfg)
// Set up Gin router
if os.Getenv("GIN_MODE") == "" {
@ -78,30 +72,8 @@ func RunServer(cfg *config.Config) error {
}
router := gin.Default()
apiGroup := router.Group("/api")
if cfg.RateLimit > 0 {
// Set up rate limiting (2x to handle burst)
rateLimitStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
Rate: 2 * time.Second,
Limit: 2 * cfg.RateLimit,
})
rateLimiter := ratelimit.RateLimiter(rateLimitStore, &ratelimit.Options{
ErrorHandler: func(c *gin.Context, info ratelimit.Info) {
c.JSON(429, gin.H{
"error": "rate_limit_exceeded",
"message": "Too many requests. Try again in " + time.Until(info.ResetTime).String(),
})
},
KeyFunc: func(c *gin.Context) string {
return c.ClientIP()
},
})
apiGroup.Use(rateLimiter)
}
// Register API routes
apiGroup := router.Group("/api")
api.RegisterHandlers(apiGroup, handler)
web.DeclareRoutes(cfg, router)

View file

@ -34,17 +34,10 @@ func declareFlags(o *Config) {
flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address")
flag.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())")
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)")
flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)")
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI")
flag.BoolVar(&o.DisableTestList, "disable-test-list", o.DisableTestList, "Disable the public test listing endpoint")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -25,7 +25,6 @@ import (
"flag"
"fmt"
"log"
"net/url"
"os"
"path"
"strings"
@ -34,11 +33,6 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
)
func getHostname() string {
h, _ := os.Hostname()
return h
}
// Config represents the application configuration
type Config struct {
DevProxy string
@ -47,10 +41,6 @@ type Config struct {
Email EmailConfig
Analysis AnalysisConfig
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
RateLimit uint // API rate limit (requests per second per IP)
SurveyURL url.URL // URL for user feedback survey
CustomLogoURL string // URL for custom logo image in the web UI
DisableTestList bool // Disable the public test listing endpoint
}
// DatabaseConfig contains database connection settings
@ -64,17 +54,13 @@ type EmailConfig struct {
Domain string
TestAddressPrefix string
LMTPAddr string
ReceiverHostname string
}
// AnalysisConfig contains timeout and behavior settings for email analysis
type AnalysisConfig struct {
DNSTimeout time.Duration
HTTPTimeout time.Duration
RBLs []string
DNSWLs []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one
RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list)
DNSTimeout time.Duration
HTTPTimeout time.Duration
RBLs []string
}
// DefaultConfig returns a configuration with sensible defaults
@ -83,7 +69,6 @@ func DefaultConfig() *Config {
DevProxy: "",
Bind: ":8080",
ReportRetention: 0, // Keep reports forever by default
RateLimit: 1, // is in fact 2 requests per 2 seconds per IP (default)
Database: DatabaseConfig{
Type: "sqlite",
DSN: "happydeliver.db",
@ -92,14 +77,11 @@ func DefaultConfig() *Config {
Domain: "happydeliver.local",
TestAddressPrefix: "test-",
LMTPAddr: "127.0.0.1:2525",
ReceiverHostname: getHostname(),
},
Analysis: AnalysisConfig{
DNSTimeout: 5 * time.Second,
HTTPTimeout: 10 * time.Second,
RBLs: []string{},
DNSWLs: []string{},
CheckAllIPs: false, // By default, only check the first IP
},
}
}

View file

@ -23,7 +23,6 @@ package config
import (
"fmt"
"net/url"
"strings"
)
@ -44,25 +43,3 @@ func (i *StringArray) Set(value string) error {
return nil
}
type URL struct {
URL *url.URL
}
func (i *URL) String() string {
if i.URL != nil {
return i.URL.String()
} else {
return ""
}
}
func (i *URL) Set(value string) error {
u, err := url.Parse(value)
if err != nil {
return err
}
*i.URL = *u
return nil
}

View file

@ -92,10 +92,6 @@ func (s *Session) Data(r io.Reader) error {
log.Printf("LMTP: Received %d bytes", len(emailData))
// Prepend Return-Path header from envelope sender
returnPath := fmt.Sprintf("Return-Path: <%s>\r\n", s.from)
emailData = append([]byte(returnPath), emailData...)
// Process email for each recipient
// LMTP requires per-recipient status, but go-smtp handles this internally
for _, recipient := range s.recipients {

View file

@ -22,7 +22,6 @@
package receiver
import (
"encoding/base32"
"encoding/json"
"fmt"
"io"
@ -96,18 +95,7 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
return fmt.Errorf("failed to analyze email: %w", err)
}
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)
}
}
log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score)
// Marshal report to JSON
reportJSON, err := json.Marshal(result.Report)
@ -124,34 +112,8 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
return nil
}
// base32ToUUID converts a URL-safe Base32 string (without padding) to a UUID
// Hyphens are ignored during decoding
func base32ToUUID(encoded string) (uuid.UUID, error) {
// Remove hyphens for decoding
encoded = strings.ReplaceAll(encoded, "-", "")
// Convert to uppercase for Base32 decoding
encoded = strings.ToUpper(encoded)
// Decode from Base32
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to decode base32: %w", err)
}
// Ensure we have exactly 16 bytes for UUID
if len(decoded) != 16 {
return uuid.Nil, fmt.Errorf("decoded bytes length is %d, expected 16", len(decoded))
}
// Convert bytes to UUID
var id uuid.UUID
copy(id[:], decoded)
return id, nil
}
// extractTestID extracts the UUID from the test email address
// Expected format: test-<base32-uuid>@domain.com
// Expected format: test-<uuid>@domain.com
func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
// Remove angle brackets if present (e.g., <test-uuid@domain.com>)
email = strings.Trim(email, "<>")
@ -171,10 +133,10 @@ func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
// Decode Base32 to UUID
testID, err := base32ToUUID(uuidStr)
// Parse UUID
testID, err := uuid.Parse(uuidStr)
if err != nil {
return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err)
return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr)
}
return testID, nil

View file

@ -30,9 +30,6 @@ import (
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
var (
@ -46,9 +43,7 @@ type Storage interface {
CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error)
GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error)
ReportExists(testID uuid.UUID) (bool, error)
UpdateReport(testID uuid.UUID, reportJSON []byte) error
DeleteOldReports(olderThan time.Time) (int64, error)
ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error)
// Close closes the database connection
Close() error
@ -112,7 +107,7 @@ func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) {
// GetReport retrieves a report by test ID, returning the raw JSON and email bytes
func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
var dbReport Report
if err := s.db.Where("test_id = ?", testID).Order("created_at DESC").First(&dbReport).Error; err != nil {
if err := s.db.First(&dbReport, "test_id = ?", testID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, ErrNotFound
}
@ -122,18 +117,6 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
return dbReport.ReportJSON, dbReport.RawEmail, nil
}
// UpdateReport updates the report JSON for an existing test ID
func (s *DBStorage) UpdateReport(testID uuid.UUID, reportJSON []byte) error {
result := s.db.Model(&Report{}).Where("test_id = ?", testID).Update("report_json", reportJSON)
if result.Error != nil {
return fmt.Errorf("failed to update report: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// DeleteOldReports deletes reports older than the specified time
func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
result := s.db.Where("created_at < ?", olderThan).Delete(&Report{})
@ -143,72 +126,6 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
return result.RowsAffected, nil
}
// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary
type reportSummaryRow struct {
TestID uuid.UUID
Score int
Grade string
FromDomain string
CreatedAt time.Time
}
// ListReportSummaries returns a paginated list of lightweight report summaries
func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) {
var total int64
if err := s.db.Model(&Report{}).Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count reports: %w", err)
}
if total == 0 {
return []model.TestSummary{}, 0, nil
}
var selectExpr string
switch s.db.Dialector.Name() {
case "postgres":
selectExpr = `test_id, ` +
`(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` +
`convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` +
`convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` +
`created_at`
case "sqlite":
selectExpr = `test_id, ` +
`json_extract(report_json, '$.score') as score, ` +
`json_extract(report_json, '$.grade') as grade, ` +
`json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` +
`created_at`
default:
return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect")
}
var rows []reportSummaryRow
err := s.db.Model(&Report{}).
Select(selectExpr).
Order("created_at DESC").
Offset(offset).
Limit(limit).
Scan(&rows).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to list report summaries: %w", err)
}
summaries := make([]model.TestSummary, 0, len(rows))
for _, r := range rows {
s := model.TestSummary{
TestId: utils.UUIDToBase32(r.TestID),
Score: r.Score,
Grade: model.TestSummaryGrade(r.Grade),
CreatedAt: r.CreatedAt,
}
if r.FromDomain != "" {
s.FromDomain = utils.PtrTo(r.FromDomain)
}
summaries = append(summaries, s)
}
return summaries, total, nil
}
// Close closes the database connection
func (s *DBStorage) Close() error {
sqlDB, err := s.db.DB()
@ -217,33 +134,3 @@ func (s *DBStorage) Close() error {
}
return sqlDB.Close()
}
// GetAllReports retrieves all reports from the database
func GetAllReports(s Storage) ([]Report, error) {
dbStorage, ok := s.(*DBStorage)
if !ok {
return nil, fmt.Errorf("storage type does not support GetAllReports")
}
var reports []Report
if err := dbStorage.db.Find(&reports).Error; err != nil {
return nil, fmt.Errorf("failed to retrieve reports: %w", err)
}
return reports, nil
}
// CreateReportFromBackup creates a report from backup data, preserving timestamps
func CreateReportFromBackup(s Storage, report *Report) (*Report, error) {
dbStorage, ok := s.(*DBStorage)
if !ok {
return nil, fmt.Errorf("storage type does not support CreateReportFromBackup")
}
// Use Create to insert the report with all fields including timestamps
if err := dbStorage.db.Create(report).Error; err != nil {
return nil, fmt.Errorf("failed to create report from backup: %w", err)
}
return report, nil
}

View file

@ -1,75 +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 utils
import (
"encoding/base32"
"fmt"
"strings"
"github.com/google/uuid"
)
// UUIDToBase32 converts a UUID to a URL-safe Base32 string (without padding)
// with hyphens every 7 characters for better readability
func UUIDToBase32(id uuid.UUID) string {
// Use RFC 4648 Base32 encoding (URL-safe)
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(id[:])
// Convert to lowercase for better readability
encoded = strings.ToLower(encoded)
// Insert hyphens every 7 characters
var result strings.Builder
for i, char := range encoded {
if i > 0 && i%7 == 0 {
result.WriteRune('-')
}
result.WriteRune(char)
}
return result.String()
}
// Base32ToUUID converts a base32-encoded string back to a UUID
// Accepts strings with or without hyphens
func Base32ToUUID(encoded string) (uuid.UUID, error) {
// Remove hyphens
encoded = strings.ReplaceAll(encoded, "-", "")
// Convert to uppercase for decoding
encoded = strings.ToUpper(encoded)
// Decode base32
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded)
if err != nil {
return uuid.UUID{}, fmt.Errorf("invalid base32 encoding: %w", err)
}
// Ensure we have exactly 16 bytes for a UUID
if len(decoded) != 16 {
return uuid.UUID{}, fmt.Errorf("invalid UUID length: expected 16 bytes, got %d", len(decoded))
}
// Convert byte slice to UUID
var id uuid.UUID
copy(id[:], decoded)
return id, nil
}

View file

@ -1,26 +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 version
// Version is the application version. It can be set at build time using ldflags:
// go build -ldflags "-X git.happydns.org/happyDeliver/internal/version.Version=1.2.3"
var Version = "(custom build)"

View file

@ -23,12 +23,11 @@ package analyzer
import (
"bytes"
"encoding/json"
"fmt"
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
)
@ -41,13 +40,9 @@ type EmailAnalyzer struct {
// NewEmailAnalyzer creates a new email analyzer with the given configuration
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
generator := NewReportGenerator(
cfg.Email.ReceiverHostname,
cfg.Analysis.DNSTimeout,
cfg.Analysis.HTTPTimeout,
cfg.Analysis.RBLs,
cfg.Analysis.DNSWLs,
cfg.Analysis.CheckAllIPs,
cfg.Analysis.RspamdAPIURL,
)
return &EmailAnalyzer{
@ -59,7 +54,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
type AnalysisResult struct {
Email *EmailMessage
Results *AnalysisResults
Report *model.Report
Report *api.Report
}
// AnalyzeEmailBytes performs complete email analysis from raw bytes
@ -83,68 +78,10 @@ func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*A
}, nil
}
// APIAdapter adapts the EmailAnalyzer to work with the API package
// This adapter implements the interface expected by the API handler
type APIAdapter struct {
analyzer *EmailAnalyzer
}
// NewAPIAdapter creates a new API adapter for the email analyzer
func NewAPIAdapter(cfg *config.Config) *APIAdapter {
return &APIAdapter{
analyzer: NewEmailAnalyzer(cfg),
}
}
// AnalyzeEmailBytes performs analysis and returns JSON bytes directly
func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byte, error) {
result, err := a.analyzer.AnalyzeEmailBytes(rawEmail, testID)
if err != nil {
return nil, err
}
// Marshal report to JSON
reportJSON, err := json.Marshal(result.Report)
if err != nil {
return nil, fmt.Errorf("failed to marshal report: %w", err)
}
return reportJSON, nil
}
// AnalyzeDomain performs DNS analysis for a domain and returns the results
func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) {
// Perform DNS analysis
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
// Calculate score
score, grade := a.analyzer.generator.dnsAnalyzer.CalculateDomainOnlyScore(dnsResults)
return dnsResults, score, grade
}
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.BlacklistCheck, int, int, string, error) {
// Check the IP against all configured RBLs
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
if err != nil {
return nil, nil, 0, 0, "", err
}
// Calculate score using the existing function
// Create a minimal RBLResults structure for scoring
results := &DNSListResults{
Checks: map[string][]model.BlacklistCheck{ip: checks},
IPsChecked: []string{ip},
ListedCount: listedCount,
}
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results, false)
// Check the IP against all configured DNSWLs (informational only)
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)
if err != nil {
whitelists = nil
}
return checks, whitelists, listedCount, score, grade, nil
// GetScoreSummaryText returns a human-readable score summary
func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string {
if result == nil || result.Results == nil {
return ""
}
return a.generator.GetScoreSummaryText(result.Results)
}

View file

@ -22,27 +22,27 @@
package analyzer
import (
"fmt"
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
// AuthenticationAnalyzer analyzes email authentication results
type AuthenticationAnalyzer struct {
receiverHostname string
}
type AuthenticationAnalyzer struct{}
// NewAuthenticationAnalyzer creates a new authentication analyzer
func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer {
return &AuthenticationAnalyzer{receiverHostname: receiverHostname}
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
return &AuthenticationAnalyzer{}
}
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults {
results := &model.AuthenticationResults{}
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
results := &api.AuthenticationResults{}
// Parse Authentication-Results headers
authHeaders := email.GetAuthenticationResults(a.receiverHostname)
authHeaders := email.GetAuthenticationResults()
for _, header := range authHeaders {
a.parseAuthenticationResultsHeader(header, results)
}
@ -52,6 +52,13 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *mod
results.Spf = a.parseLegacySPF(email)
}
if results.Dkim == nil || len(*results.Dkim) == 0 {
dkimResults := a.parseLegacyDKIM(email)
if len(dkimResults) > 0 {
results.Dkim = &dkimResults
}
}
// Parse ARC headers if not already parsed from Authentication-Results
if results.Arc == nil {
results.Arc = a.parseARCHeaders(email)
@ -65,7 +72,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *mod
// parseAuthenticationResultsHeader parses an Authentication-Results header
// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) {
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) {
// Split by semicolon to get individual results
parts := strings.Split(header, ";")
if len(parts) < 2 {
@ -91,7 +98,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
dkimResult := a.parseDKIMResult(part)
if dkimResult != nil {
if results.Dkim == nil {
dkimList := []model.AuthResult{*dkimResult}
dkimList := []api.AuthResult{*dkimResult}
results.Dkim = &dkimList
} else {
*results.Dkim = append(*results.Dkim, *dkimResult)
@ -119,69 +126,382 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
results.Arc = a.parseARCResult(part)
}
}
// Parse IPRev
if strings.HasPrefix(part, "iprev=") {
if results.Iprev == nil {
results.Iprev = a.parseIPRevResult(part)
}
}
// Parse x-google-dkim
if strings.HasPrefix(part, "x-google-dkim=") {
if results.XGoogleDkim == nil {
results.XGoogleDkim = a.parseXGoogleDKIMResult(part)
}
}
// Parse x-aligned-from
if strings.HasPrefix(part, "x-aligned-from=") {
if results.XAlignedFrom == nil {
results.XAlignedFrom = a.parseXAlignedFromResult(part)
}
}
}
}
// CalculateAuthenticationScore calculates the authentication score from auth results
// Returns a score from 0-100 where higher is better
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) {
if results == nil {
return 0, ""
// parseSPFResult parses SPF result from Authentication-Results
// Example: spf=pass smtp.mailfrom=sender@example.com
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`spf=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
}
score := 0
// Core authentication (90 points total)
// SPF (30 points)
score += 30 * a.calculateSPFScore(results) / 100
// DKIM (30 points)
score += 30 * a.calculateDKIMScore(results) / 100
// DMARC (30 points)
score += 30 * a.calculateDMARCScore(results) / 100
// BIMI (10 points)
score += 10 * a.calculateBIMIScore(results) / 100
// Penalty-only: IPRev (up to -7 points on failure)
if iprevScore := a.calculateIPRevScore(results); iprevScore < 100 {
score += 7 * (iprevScore - 100) / 100
// Extract domain
domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
email := matches[1]
// Extract domain from email
if idx := strings.Index(email, "@"); idx != -1 {
domain := email[idx+1:]
result.Domain = &domain
}
}
// Penalty-only: X-Google-DKIM (up to -12 points on failure)
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
// Penalty-only: X-Aligned-From (up to -5 points on failure)
if xAlignedScore := a.calculateXAlignedFromScore(results); xAlignedScore < 100 {
score += 5 * (xAlignedScore - 100) / 100
// Extract details
if idx := strings.Index(part, "("); idx != -1 {
endIdx := strings.Index(part[idx:], ")")
if endIdx != -1 {
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
result.Details = &details
}
}
// Ensure score doesn't exceed 100
if score > 100 {
score = 100
}
return score, ScoreToGrade(score)
return result
}
// parseDKIMResult parses DKIM result from Authentication-Results
// Example: dkim=pass header.d=example.com header.s=selector1
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`dkim=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract selector (header.s or s)
selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`)
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
selector := matches[1]
result.Selector = &selector
}
// Extract details
if idx := strings.Index(part, "("); idx != -1 {
endIdx := strings.Index(part[idx:], ")")
if endIdx != -1 {
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
result.Details = &details
}
}
return result
}
// parseDMARCResult parses DMARC result from Authentication-Results
// Example: dmarc=pass action=none header.from=example.com
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`dmarc=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
}
// Extract domain (header.from)
domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract details (action, policy, etc.)
var detailsParts []string
actionRe := regexp.MustCompile(`action=([^\s;]+)`)
if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 {
detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1]))
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, " ")
result.Details = &details
}
return result
}
// parseBIMIResult parses BIMI result from Authentication-Results
// Example: bimi=pass header.d=example.com header.selector=default
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`bimi=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract selector (header.selector or selector)
selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`)
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
selector := matches[1]
result.Selector = &selector
}
// Extract details
if idx := strings.Index(part, "("); idx != -1 {
endIdx := strings.Index(part[idx:], ")")
if endIdx != -1 {
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
result.Details = &details
}
}
return result
}
// parseARCResult parses ARC result from Authentication-Results
// Example: arc=pass
func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
result := &api.ARCResult{}
// Extract result (pass, fail, none)
re := regexp.MustCompile(`arc=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.ARCResultResult(resultStr)
}
// Extract details
if idx := strings.Index(part, "("); idx != -1 {
endIdx := strings.Index(part[idx:], ")")
if endIdx != -1 {
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
result.Details = &details
}
}
return result
}
// parseARCHeaders parses ARC headers from email message
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
// Get all ARC-related headers
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
// If no ARC headers present, return nil
if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 {
return nil
}
result := &api.ARCResult{
Result: api.ARCResultResultNone,
}
// Count the ARC chain length (number of sets)
chainLength := len(arcSeal)
result.ChainLength = &chainLength
// Validate the ARC chain
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
result.ChainValid = &chainValid
// Determine overall result
if chainLength == 0 {
result.Result = api.ARCResultResultNone
details := "No ARC chain present"
result.Details = &details
} else if !chainValid {
result.Result = api.ARCResultResultFail
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
result.Details = &details
} else {
result.Result = api.ARCResultResultPass
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
result.Details = &details
}
return result
}
// enhanceARCResult enhances an existing ARC result with chain information
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
if arcResult == nil {
return
}
// Get ARC headers
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
// Set chain length if not already set
if arcResult.ChainLength == nil {
chainLength := len(arcSeal)
arcResult.ChainLength = &chainLength
}
// Validate chain if not already validated
if arcResult.ChainValid == nil {
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
arcResult.ChainValid = &chainValid
}
}
// validateARCChain validates the ARC chain for completeness
// Each instance should have all three headers with matching instance numbers
func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool {
// All three header types should have the same count
if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) {
return false
}
if len(arcSeal) == 0 {
return true // No ARC chain is technically valid
}
// Extract instance numbers from each header type
sealInstances := a.extractARCInstances(arcSeal)
sigInstances := a.extractARCInstances(arcMessageSig)
authInstances := a.extractARCInstances(arcAuthResults)
// Check that all instance numbers match and are sequential starting from 1
if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) {
return false
}
// Verify instances are sequential from 1 to N
for i := 1; i <= len(sealInstances); i++ {
if !contains(sealInstances, i) || !contains(sigInstances, i) || !contains(authInstances, i) {
return false
}
}
return true
}
// extractARCInstances extracts instance numbers from ARC headers
func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int {
var instances []int
re := regexp.MustCompile(`i=(\d+)`)
for _, header := range headers {
if matches := re.FindStringSubmatch(header); len(matches) > 1 {
var instance int
fmt.Sscanf(matches[1], "%d", &instance)
instances = append(instances, instance)
}
}
return instances
}
// contains checks if a slice contains an integer
func contains(slice []int, val int) bool {
for _, item := range slice {
if item == val {
return true
}
}
return false
}
// pluralize returns "y" or "ies" based on count
func pluralize(count int) string {
if count == 1 {
return "y"
}
return "ies"
}
// parseLegacySPF attempts to parse SPF from Received-SPF header
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
receivedSPF := email.Header.Get("Received-SPF")
if receivedSPF == "" {
return nil
}
result := &api.AuthResult{}
// Extract result (first word)
parts := strings.Fields(receivedSPF)
if len(parts) > 0 {
resultStr := strings.ToLower(parts[0])
result.Result = api.AuthResultResult(resultStr)
}
// Try to extract domain
domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
email := matches[1]
if idx := strings.Index(email, "@"); idx != -1 {
domain := email[idx+1:]
result.Domain = &domain
}
}
return result
}
// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header
func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult {
var results []api.AuthResult
// Get all DKIM-Signature headers
dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")]
for _, dkimHeader := range dkimHeaders {
result := api.AuthResult{
Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone
}
// Extract domain (d=)
domainRe := regexp.MustCompile(`d=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract selector (s=)
selectorRe := regexp.MustCompile(`s=([^\s;]+)`)
if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 {
selector := matches[1]
result.Selector = &selector
}
details := "DKIM signature present (verification status unknown)"
result.Details = &details
results = append(results, result)
}
return results
}
// textprotoCanonical converts a header name to canonical form
func textprotoCanonical(s string) string {
// Simple implementation - capitalize each word
words := strings.Split(s, "-")
for i, word := range words {
if len(word) > 0 {
words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
}
}
return strings.Join(words, "-")
}

View file

@ -1,184 +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"
"slices"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// textprotoCanonical converts a header name to canonical form
func textprotoCanonical(s string) string {
// Simple implementation - capitalize each word
words := strings.Split(s, "-")
for i, word := range words {
if len(word) > 0 {
words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
}
}
return strings.Join(words, "-")
}
// pluralize returns "y" or "ies" based on count
func pluralize(count int) string {
if count == 1 {
return "y"
}
return "ies"
}
// parseARCResult parses ARC result from Authentication-Results
// Example: arc=pass
func (a *AuthenticationAnalyzer) parseARCResult(part string) *model.ARCResult {
result := &model.ARCResult{}
// Extract result (pass, fail, none)
re := regexp.MustCompile(`arc=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.ARCResultResult(resultStr)
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "arc="))
return result
}
// parseARCHeaders parses ARC headers from email message
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARCResult {
// Get all ARC-related headers
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
// If no ARC headers present, return nil
if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 {
return nil
}
result := &model.ARCResult{
Result: model.ARCResultResultNone,
}
// Count the ARC chain length (number of sets)
chainLength := len(arcSeal)
result.ChainLength = &chainLength
// Validate the ARC chain
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
result.ChainValid = &chainValid
// Determine overall result
if chainLength == 0 {
result.Result = model.ARCResultResultNone
details := "No ARC chain present"
result.Details = &details
} else if !chainValid {
result.Result = model.ARCResultResultFail
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
result.Details = &details
} else {
result.Result = model.ARCResultResultPass
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
result.Details = &details
}
return result
}
// enhanceARCResult enhances an existing ARC result with chain information
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) {
if arcResult == nil {
return
}
// Get ARC headers
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
// Set chain length if not already set
if arcResult.ChainLength == nil {
chainLength := len(arcSeal)
arcResult.ChainLength = &chainLength
}
// Validate chain if not already validated
if arcResult.ChainValid == nil {
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
arcResult.ChainValid = &chainValid
}
}
// validateARCChain validates the ARC chain for completeness
// Each instance should have all three headers with matching instance numbers
func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool {
// All three header types should have the same count
if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) {
return false
}
if len(arcSeal) == 0 {
return true // No ARC chain is technically valid
}
// Extract instance numbers from each header type
sealInstances := a.extractARCInstances(arcSeal)
sigInstances := a.extractARCInstances(arcMessageSig)
authInstances := a.extractARCInstances(arcAuthResults)
// Check that all instance numbers match and are sequential starting from 1
if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) {
return false
}
// Verify instances are sequential from 1 to N
for i := 1; i <= len(sealInstances); i++ {
if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) {
return false
}
}
return true
}
// extractARCInstances extracts instance numbers from ARC headers
func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int {
var instances []int
re := regexp.MustCompile(`i=(\d+)`)
for _, header := range headers {
if matches := re.FindStringSubmatch(header); len(matches) > 1 {
var instance int
fmt.Sscanf(matches[1], "%d", &instance)
instances = append(instances, instance)
}
}
return instances
}

View file

@ -1,150 +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 (
"testing"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseARCResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.ARCResultResult
}{
{
name: "ARC pass",
part: "arc=pass",
expectedResult: model.ARCResultResultPass,
},
{
name: "ARC fail",
part: "arc=fail",
expectedResult: model.ARCResultResultFail,
},
{
name: "ARC none",
part: "arc=none",
expectedResult: model.ARCResultResultNone,
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseARCResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
})
}
}
func TestValidateARCChain(t *testing.T) {
tests := []struct {
name string
arcAuthResults []string
arcMessageSig []string
arcSeal []string
expectedValid bool
}{
{
name: "Empty chain is valid",
arcAuthResults: []string{},
arcMessageSig: []string{},
arcSeal: []string{},
expectedValid: true,
},
{
name: "Valid chain with single hop",
arcAuthResults: []string{
"i=1; example.com; spf=pass",
},
arcMessageSig: []string{
"i=1; a=rsa-sha256; d=example.com",
},
arcSeal: []string{
"i=1; a=rsa-sha256; s=arc; d=example.com",
},
expectedValid: true,
},
{
name: "Valid chain with two hops",
arcAuthResults: []string{
"i=1; example.com; spf=pass",
"i=2; relay.com; arc=pass",
},
arcMessageSig: []string{
"i=1; a=rsa-sha256; d=example.com",
"i=2; a=rsa-sha256; d=relay.com",
},
arcSeal: []string{
"i=1; a=rsa-sha256; s=arc; d=example.com",
"i=2; a=rsa-sha256; s=arc; d=relay.com",
},
expectedValid: true,
},
{
name: "Invalid chain - missing one header type",
arcAuthResults: []string{
"i=1; example.com; spf=pass",
},
arcMessageSig: []string{
"i=1; a=rsa-sha256; d=example.com",
},
arcSeal: []string{},
expectedValid: false,
},
{
name: "Invalid chain - non-sequential instances",
arcAuthResults: []string{
"i=1; example.com; spf=pass",
"i=3; relay.com; arc=pass",
},
arcMessageSig: []string{
"i=1; a=rsa-sha256; d=example.com",
"i=3; a=rsa-sha256; d=relay.com",
},
arcSeal: []string{
"i=1; a=rsa-sha256; s=arc; d=example.com",
"i=3; a=rsa-sha256; s=arc; d=relay.com",
},
expectedValid: false,
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal)
if valid != tt.expectedValid {
t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid)
}
})
}
}

View file

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

View file

@ -1,94 +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 (
"testing"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseBIMIResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "BIMI pass with domain and selector",
part: "bimi=pass header.d=example.com header.selector=default",
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "BIMI fail",
part: "bimi=fail header.d=example.com header.selector=default",
expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "BIMI with short form (d= and selector=)",
part: "bimi=pass d=example.com selector=v1",
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "v1",
},
{
name: "BIMI none",
part: "bimi=none header.d=example.com",
expectedResult: model.AuthResultResultNone,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseBIMIResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if result.Domain == nil || *result.Domain != tt.expectedDomain {
var gotDomain string
if result.Domain != nil {
gotDomain = *result.Domain
}
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
}
if tt.expectedSelector != "" {
if result.Selector == nil || *result.Selector != tt.expectedSelector {
var gotSelector string
if result.Selector != nil {
gotSelector = *result.Selector
}
t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector)
}
}
})
}
}

View file

@ -0,0 +1,304 @@
// 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"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// GenerateAuthenticationChecks generates check results for authentication
func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check {
var checks []api.Check
// SPF check
if results.Spf != nil {
check := a.generateSPFCheck(results.Spf)
checks = append(checks, check)
} else {
checks = append(checks, api.Check{
Category: api.Authentication,
Name: "SPF Record",
Status: api.CheckStatusWarn,
Score: 0.0,
Message: "No SPF authentication result found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"),
})
}
// DKIM check
if results.Dkim != nil && len(*results.Dkim) > 0 {
for i, dkim := range *results.Dkim {
check := a.generateDKIMCheck(&dkim, i)
checks = append(checks, check)
}
} else {
checks = append(checks, api.Check{
Category: api.Authentication,
Name: "DKIM Signature",
Status: api.CheckStatusWarn,
Score: 0.0,
Message: "No DKIM signature found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"),
})
}
// DMARC check
if results.Dmarc != nil {
check := a.generateDMARCCheck(results.Dmarc)
checks = append(checks, check)
} else {
checks = append(checks, api.Check{
Category: api.Authentication,
Name: "DMARC Policy",
Status: api.CheckStatusWarn,
Score: 0.0,
Message: "No DMARC authentication result found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Implement DMARC policy for your domain"),
})
}
// BIMI check (optional, informational only)
if results.Bimi != nil {
check := a.generateBIMICheck(results.Bimi)
checks = append(checks, check)
}
// ARC check (optional, for forwarded emails)
if results.Arc != nil {
check := a.generateARCCheck(results.Arc)
checks = append(checks, check)
}
return checks
}
func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check {
check := api.Check{
Category: api.Authentication,
Name: "SPF Record",
}
switch spf.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "SPF validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your SPF record is properly configured")
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = "SPF validation failed"
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
case api.AuthResultResultSoftfail:
check.Status = api.CheckStatusWarn
check.Score = 0.5
check.Message = "SPF validation softfail"
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Review your SPF record configuration")
case api.AuthResultResultNeutral:
check.Status = api.CheckStatusWarn
check.Score = 0.5
check.Message = "SPF validation neutral"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("Consider tightening your SPF policy")
default:
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Review your SPF record configuration")
}
if spf.Domain != nil {
details := fmt.Sprintf("Domain: %s", *spf.Domain)
check.Details = &details
}
return check
}
func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check {
check := api.Check{
Category: api.Authentication,
Name: fmt.Sprintf("DKIM Signature #%d", index+1),
}
switch dkim.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "DKIM signature is valid"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your DKIM signature is properly configured")
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = "DKIM signature validation failed"
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
default:
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
}
var detailsParts []string
if dkim.Domain != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
}
if dkim.Selector != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, ", ")
check.Details = &details
}
return check
}
func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check {
check := api.Check{
Category: api.Authentication,
Name: "DMARC Policy",
}
switch dmarc.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "DMARC validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = "DMARC validation failed"
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
default:
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Configure DMARC policy for your domain")
}
if dmarc.Domain != nil {
details := fmt.Sprintf("Domain: %s", *dmarc.Domain)
check.Details = &details
}
return check
}
func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check {
check := api.Check{
Category: api.Authentication,
Name: "BIMI (Brand Indicators)",
}
switch bimi.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
check.Message = "BIMI validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI")
case api.AuthResultResultFail:
check.Status = api.CheckStatusInfo
check.Score = 0.0
check.Message = "BIMI validation failed"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record")
default:
check.Status = api.CheckStatusInfo
check.Score = 0.0
check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients")
}
if bimi.Domain != nil {
details := fmt.Sprintf("Domain: %s", *bimi.Domain)
check.Details = &details
}
return check
}
func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check {
check := api.Check{
Category: api.Authentication,
Name: "ARC (Authenticated Received Chain)",
}
switch arc.Result {
case api.ARCResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding)
check.Message = "ARC chain validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication")
case api.ARCResultResultFail:
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Message = "ARC chain validation failed"
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries")
default:
check.Status = api.CheckStatusInfo
check.Score = 0.0
check.Message = "No ARC chain present"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries")
}
// Build details
var detailsParts []string
if arc.ChainLength != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength))
}
if arc.ChainValid != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid))
}
if arc.Details != nil {
detailsParts = append(detailsParts, *arc.Details)
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, ", ")
check.Details = &details
}
return check
}

View file

@ -1,87 +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 (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseDKIMResult parses DKIM result from Authentication-Results
// Example: dkim=pass header.d=example.com header.s=selector1
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult {
result := &model.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`dkim=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract selector (header.s or s)
selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`)
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
selector := matches[1]
result.Selector = &selector
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim="))
return result
}
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *model.AuthenticationResults) (score int) {
// Expect at least one passing signature
if results.Dkim != nil && len(*results.Dkim) > 0 {
hasPass := false
hasNonPass := false
for _, dkim := range *results.Dkim {
if dkim.Result == model.AuthResultResultPass {
hasPass = true
} else {
hasNonPass = true
}
}
if hasPass && hasNonPass {
// Could be better
return 90
} else if hasPass {
return 100
} else {
// Has DKIM signatures but none passed
return 20
}
}
return 0
}

View file

@ -1,86 +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 (
"testing"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "DKIM pass with domain and selector",
part: "dkim=pass header.d=example.com header.s=default",
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "DKIM fail",
part: "dkim=fail header.d=example.com header.s=selector1",
expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
expectedSelector: "selector1",
},
{
name: "DKIM with short form (d= and s=)",
part: "dkim=pass d=example.com s=default",
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseDKIMResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if result.Domain == nil || *result.Domain != tt.expectedDomain {
var gotDomain string
if result.Domain != nil {
gotDomain = *result.Domain
}
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
}
if result.Selector == nil || *result.Selector != tt.expectedSelector {
var gotSelector string
if result.Selector != nil {
gotSelector = *result.Selector
}
t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector)
}
})
}
}

View file

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

View file

@ -1,69 +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 (
"testing"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseDMARCResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedDomain string
}{
{
name: "DMARC pass",
part: "dmarc=pass action=none header.from=example.com",
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "DMARC fail",
part: "dmarc=fail action=quarantine header.from=example.com",
expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseDMARCResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if result.Domain == nil || *result.Domain != tt.expectedDomain {
var gotDomain string
if result.Domain != nil {
gotDomain = *result.Domain
}
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
}
})
}
}

View file

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

View file

@ -1,226 +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 (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
func TestParseIPRevResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.IPRevResultResult
expectedIP *string
expectedHostname *string
}{
{
name: "IPRev pass with IP and hostname",
part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
expectedResult: model.Pass,
expectedIP: utils.PtrTo("195.110.101.58"),
expectedHostname: utils.PtrTo("authsmtp74.register.it"),
},
{
name: "IPRev pass without smtp prefix",
part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
expectedResult: model.Pass,
expectedIP: utils.PtrTo("192.0.2.1"),
expectedHostname: utils.PtrTo("mail.example.com"),
},
{
name: "IPRev fail",
part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
expectedResult: model.Fail,
expectedIP: utils.PtrTo("198.51.100.42"),
expectedHostname: utils.PtrTo("unknown.host.com"),
},
{
name: "IPRev temperror",
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
expectedResult: model.Temperror,
expectedIP: utils.PtrTo("203.0.113.1"),
expectedHostname: nil,
},
{
name: "IPRev permerror",
part: "iprev=permerror smtp.remote-ip=192.0.2.100",
expectedResult: model.Permerror,
expectedIP: utils.PtrTo("192.0.2.100"),
expectedHostname: nil,
},
{
name: "IPRev with IPv6",
part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
expectedResult: model.Pass,
expectedIP: utils.PtrTo("2001:db8::1"),
expectedHostname: utils.PtrTo("ipv6.example.com"),
},
{
name: "IPRev with subdomain hostname",
part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
expectedResult: model.Pass,
expectedIP: utils.PtrTo("192.0.2.50"),
expectedHostname: utils.PtrTo("mail.subdomain.example.com"),
},
{
name: "IPRev pass without parentheses",
part: "iprev=pass smtp.remote-ip=192.0.2.200",
expectedResult: model.Pass,
expectedIP: utils.PtrTo("192.0.2.200"),
expectedHostname: nil,
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseIPRevResult(tt.part)
// Check result
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
// Check IP
if tt.expectedIP != nil {
if result.Ip == nil {
t.Errorf("IP = nil, want %v", *tt.expectedIP)
} else if *result.Ip != *tt.expectedIP {
t.Errorf("IP = %v, want %v", *result.Ip, *tt.expectedIP)
}
} else {
if result.Ip != nil {
t.Errorf("IP = %v, want nil", *result.Ip)
}
}
// Check hostname
if tt.expectedHostname != nil {
if result.Hostname == nil {
t.Errorf("Hostname = nil, want %v", *tt.expectedHostname)
} else if *result.Hostname != *tt.expectedHostname {
t.Errorf("Hostname = %v, want %v", *result.Hostname, *tt.expectedHostname)
}
} else {
if result.Hostname != nil {
t.Errorf("Hostname = %v, want nil", *result.Hostname)
}
}
// Check details
if result.Details == nil {
t.Error("Expected Details to be set, got nil")
}
})
}
}
func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
tests := []struct {
name string
header string
expectedIPRevResult *model.IPRevResultResult
expectedIP *string
expectedHostname *string
}{
{
name: "IPRev pass in Authentication-Results",
header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
expectedIPRevResult: utils.PtrTo(model.Pass),
expectedIP: utils.PtrTo("195.110.101.58"),
expectedHostname: utils.PtrTo("authsmtp74.register.it"),
},
{
name: "IPRev with other authentication methods",
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com",
expectedIPRevResult: utils.PtrTo(model.Pass),
expectedIP: utils.PtrTo("192.0.2.1"),
expectedHostname: utils.PtrTo("mail.example.com"),
},
{
name: "IPRev fail",
header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
expectedIPRevResult: utils.PtrTo(model.Fail),
expectedIP: utils.PtrTo("198.51.100.42"),
expectedHostname: nil,
},
{
name: "No IPRev in header",
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com",
expectedIPRevResult: nil,
},
{
name: "Multiple IPRev results - only first is parsed",
header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)",
expectedIPRevResult: utils.PtrTo(model.Pass),
expectedIP: utils.PtrTo("192.0.2.1"),
expectedHostname: utils.PtrTo("first.com"),
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &model.AuthenticationResults{}
analyzer.parseAuthenticationResultsHeader(tt.header, results)
// Check IPRev
if tt.expectedIPRevResult != nil {
if results.Iprev == nil {
t.Errorf("Expected IPRev result, got nil")
} else {
if results.Iprev.Result != *tt.expectedIPRevResult {
t.Errorf("IPRev Result = %v, want %v", results.Iprev.Result, *tt.expectedIPRevResult)
}
if tt.expectedIP != nil {
if results.Iprev.Ip == nil || *results.Iprev.Ip != *tt.expectedIP {
var gotIP string
if results.Iprev.Ip != nil {
gotIP = *results.Iprev.Ip
}
t.Errorf("IPRev IP = %v, want %v", gotIP, *tt.expectedIP)
}
}
if tt.expectedHostname != nil {
if results.Iprev.Hostname == nil || *results.Iprev.Hostname != *tt.expectedHostname {
var gotHostname string
if results.Iprev.Hostname != nil {
gotHostname = *results.Iprev.Hostname
}
t.Errorf("IPRev Hostname = %v, want %v", gotHostname, *tt.expectedHostname)
}
}
}
} else {
if results.Iprev != nil {
t.Errorf("Expected no IPRev result, got %+v", results.Iprev)
}
}
})
}
}

View file

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

View file

@ -1,213 +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 (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
func TestParseSPFResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedDomain string
}{
{
name: "SPF pass with domain",
part: "spf=pass smtp.mailfrom=sender@example.com",
expectedResult: model.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "SPF fail",
part: "spf=fail smtp.mailfrom=sender@example.com",
expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "SPF neutral",
part: "spf=neutral smtp.mailfrom=sender@example.com",
expectedResult: model.AuthResultResultNeutral,
expectedDomain: "example.com",
},
{
name: "SPF softfail",
part: "spf=softfail smtp.mailfrom=sender@example.com",
expectedResult: model.AuthResultResultSoftfail,
expectedDomain: "example.com",
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseSPFResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if result.Domain == nil || *result.Domain != tt.expectedDomain {
var gotDomain string
if result.Domain != nil {
gotDomain = *result.Domain
}
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
}
})
}
}
func TestParseLegacySPF(t *testing.T) {
tests := []struct {
name string
receivedSPF string
expectedResult model.AuthResultResult
expectedDomain *string
expectNil bool
}{
{
name: "SPF pass with envelope-from",
receivedSPF: `pass
(mail.example.com: 192.0.2.10 is authorized to use 'user@example.com' in 'mfrom' identity (mechanism 'ip4:192.0.2.10' matched))
receiver=mx.receiver.com;
identity=mailfrom;
envelope-from="user@example.com";
helo=smtp.example.com;
client-ip=192.0.2.10`,
expectedResult: model.AuthResultResultPass,
expectedDomain: utils.PtrTo("example.com"),
},
{
name: "SPF fail with sender",
receivedSPF: `fail
(mail.example.com: domain of sender@test.com does not designate 192.0.2.20 as permitted sender)
receiver=mx.receiver.com;
identity=mailfrom;
sender="sender@test.com";
helo=smtp.test.com;
client-ip=192.0.2.20`,
expectedResult: model.AuthResultResultFail,
expectedDomain: utils.PtrTo("test.com"),
},
{
name: "SPF softfail",
receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"",
expectedResult: model.AuthResultResultSoftfail,
expectedDomain: utils.PtrTo("example.org"),
},
{
name: "SPF neutral",
receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"",
expectedResult: model.AuthResultResultNeutral,
expectedDomain: utils.PtrTo("domain.net"),
},
{
name: "SPF none",
receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"",
expectedResult: model.AuthResultResultNone,
expectedDomain: utils.PtrTo("company.io"),
},
{
name: "SPF temperror",
receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"",
expectedResult: model.AuthResultResultTemperror,
expectedDomain: utils.PtrTo("shop.example"),
},
{
name: "SPF permerror",
receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"",
expectedResult: model.AuthResultResultPermerror,
expectedDomain: utils.PtrTo("invalid.test"),
},
{
name: "SPF pass without domain extraction",
receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
expectedResult: model.AuthResultResultPass,
expectedDomain: nil,
},
{
name: "Empty Received-SPF header",
receivedSPF: "",
expectNil: true,
},
{
name: "SPF with unquoted envelope-from",
receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net",
expectedResult: model.AuthResultResultPass,
expectedDomain: utils.PtrTo("mail.example.net"),
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock email message with Received-SPF header
email := &EmailMessage{
Header: make(map[string][]string),
}
if tt.receivedSPF != "" {
email.Header["Received-Spf"] = []string{tt.receivedSPF}
}
result := analyzer.parseLegacySPF(email)
if tt.expectNil {
if result != nil {
t.Errorf("Expected nil result, got %+v", result)
}
return
}
if result == nil {
t.Fatal("Expected non-nil result, got nil")
}
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if tt.expectedDomain != nil {
if result.Domain == nil {
t.Errorf("Domain = nil, want %v", *tt.expectedDomain)
} else if *result.Domain != *tt.expectedDomain {
t.Errorf("Domain = %v, want %v", *result.Domain, *tt.expectedDomain)
}
} else {
if result.Domain != nil {
t.Errorf("Domain = %v, want nil", *result.Domain)
}
}
if result.Details == nil {
t.Error("Expected Details to be set, got nil")
} else if *result.Details != tt.receivedSPF {
t.Errorf("Details = %v, want %v", *result.Details, tt.receivedSPF)
}
})
}
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,144 +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 (
"testing"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseXAlignedFromResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedDetail string
}{
{
name: "x-aligned-from pass with details",
part: "x-aligned-from=pass (Address match)",
expectedResult: model.AuthResultResultPass,
expectedDetail: "pass (Address match)",
},
{
name: "x-aligned-from fail with reason",
part: "x-aligned-from=fail (Address mismatch)",
expectedResult: model.AuthResultResultFail,
expectedDetail: "fail (Address mismatch)",
},
{
name: "x-aligned-from pass minimal",
part: "x-aligned-from=pass",
expectedResult: model.AuthResultResultPass,
expectedDetail: "pass",
},
{
name: "x-aligned-from neutral",
part: "x-aligned-from=neutral (No alignment check performed)",
expectedResult: model.AuthResultResultNeutral,
expectedDetail: "neutral (No alignment check performed)",
},
{
name: "x-aligned-from none",
part: "x-aligned-from=none",
expectedResult: model.AuthResultResultNone,
expectedDetail: "none",
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseXAlignedFromResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if result.Details == nil {
t.Errorf("Details = nil, want %v", tt.expectedDetail)
} else if *result.Details != tt.expectedDetail {
t.Errorf("Details = %v, want %v", *result.Details, tt.expectedDetail)
}
})
}
}
func TestCalculateXAlignedFromScore(t *testing.T) {
tests := []struct {
name string
result *model.AuthResult
expectedScore int
}{
{
name: "pass result gives positive score",
result: &model.AuthResult{
Result: model.AuthResultResultPass,
},
expectedScore: 100,
},
{
name: "fail result gives zero score",
result: &model.AuthResult{
Result: model.AuthResultResultFail,
},
expectedScore: 0,
},
{
name: "neutral result gives zero score",
result: &model.AuthResult{
Result: model.AuthResultResultNeutral,
},
expectedScore: 0,
},
{
name: "none result gives zero score",
result: &model.AuthResult{
Result: model.AuthResultResultNone,
},
expectedScore: 0,
},
{
name: "nil result gives zero score",
result: nil,
expectedScore: 0,
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &model.AuthenticationResults{
XAlignedFrom: tt.result,
}
score := analyzer.calculateXAlignedFromScore(results)
if score != tt.expectedScore {
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
}
})
}
}

View file

@ -1,74 +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 (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results
// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6
func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.AuthResult {
result := &model.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`x-google-dkim=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.AuthResultResult(resultStr)
}
// Extract domain (header.d or d)
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract selector (header.s or s) - though not always present in x-google-dkim
selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`)
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
selector := matches[1]
result.Selector = &selector
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
return result
}
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) {
if results.XGoogleDkim != nil {
switch results.XGoogleDkim.Result {
case model.AuthResultResultPass:
// pass: don't alter the score
default: // fail
return -100
}
}
return 0
}

View file

@ -1,83 +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 (
"testing"
"git.happydns.org/happyDeliver/internal/model"
)
func TestParseXGoogleDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult model.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "x-google-dkim pass with domain",
part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6",
expectedResult: model.AuthResultResultPass,
expectedDomain: "1e100.net",
},
{
name: "x-google-dkim pass with short form",
part: "x-google-dkim=pass d=gmail.com",
expectedResult: model.AuthResultResultPass,
expectedDomain: "gmail.com",
},
{
name: "x-google-dkim fail",
part: "x-google-dkim=fail header.d=example.com",
expectedResult: model.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "x-google-dkim with minimal info",
part: "x-google-dkim=pass",
expectedResult: model.AuthResultResultPass,
},
}
analyzer := NewAuthenticationAnalyzer("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.parseXGoogleDKIMResult(tt.part)
if result.Result != tt.expectedResult {
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
}
if tt.expectedDomain != "" {
if result.Domain == nil || *result.Domain != tt.expectedDomain {
var gotDomain string
if result.Domain != nil {
gotDomain = *result.Domain
}
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
}
}
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,7 @@ import (
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
"golang.org/x/net/html"
)
@ -76,17 +77,17 @@ func TestExtractTextFromHTML(t *testing.T) {
{
name: "Multiple elements",
html: "<div><h1>Title</h1><p>Paragraph</p></div>",
expectedText: "Title Paragraph",
expectedText: "TitleParagraph",
},
{
name: "With script tag",
html: "<p>Text</p><script>alert('hi')</script><p>More</p>",
expectedText: "Text More",
expectedText: "TextMore",
},
{
name: "With style tag",
html: "<p>Text</p><style>.class { color: red; }</style><p>More</p>",
expectedText: "Text More",
expectedText: "TextMore",
},
{
name: "Empty HTML",
@ -144,74 +145,6 @@ func TestIsUnsubscribeLink(t *testing.T) {
linkText: "Read more",
expected: false,
},
// Multilingual keyword detection - URL path
{
name: "German abmelden in URL",
href: "https://example.com/abmelden?id=42",
linkText: "Click here",
expected: true,
},
{
name: "French se-desabonner slug in URL (no accent/space - not detected by keyword)",
href: "https://example.com/se-desabonner?id=42",
linkText: "Click here",
expected: false,
},
// Multilingual keyword detection - link text
{
name: "German Abmelden in link text",
href: "https://example.com/manage?id=42&lang=de",
linkText: "Abmelden",
expected: true,
},
{
name: "French Se désabonner in link text",
href: "https://example.com/manage?id=42&lang=fr",
linkText: "Se désabonner",
expected: true,
},
{
name: "Russian Отписаться in link text",
href: "https://example.com/manage?id=42&lang=ru",
linkText: "Отписаться",
expected: true,
},
{
name: "Chinese 退订 in link text",
href: "https://example.com/manage?id=42&lang=zh",
linkText: "退订",
expected: true,
},
{
name: "Japanese 登録を取り消す in link text",
href: "https://example.com/manage?id=42&lang=ja",
linkText: "登録を取り消す",
expected: true,
},
{
name: "Korean 구독 해지 in link text",
href: "https://example.com/manage?id=42&lang=ko",
linkText: "구독 해지",
expected: true,
},
{
name: "Dutch Uitschrijven in link text",
href: "https://example.com/manage?id=42&lang=nl",
linkText: "Uitschrijven",
expected: true,
},
{
name: "Polish Odsubskrybuj in link text",
href: "https://example.com/manage?id=42&lang=pl",
linkText: "Odsubskrybuj",
expected: true,
},
{
name: "Turkish Üyeliği sonlandır in link text",
href: "https://example.com/manage?id=42&lang=tr",
linkText: "Üyeliği sonlandır",
expected: true,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
@ -281,16 +214,6 @@ func TestIsSuspiciousURL(t *testing.T) {
url: "https://mail.example.com/page",
expected: false,
},
{
name: "Mailto with @ symbol",
url: "mailto:support@example.com",
expected: false,
},
{
name: "Mailto with multiple @ symbols",
url: "mailto:user@subdomain@example.com",
expected: false,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
@ -685,6 +608,453 @@ func TestAnalyzeContent_ImageAltAttributes(t *testing.T) {
}
}
func TestGenerateHTMLValidityCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid HTML",
results: &ContentResults{
HTMLValid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.2,
},
{
name: "Invalid HTML",
results: &ContentResults{
HTMLValid: false,
HTMLErrors: []string{"Parse error"},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateHTMLValidityCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
})
}
}
func TestGenerateLinkChecks(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "All links valid",
results: &ContentResults{
Links: []LinkCheck{
{URL: "https://example.com", Valid: true, Status: 200},
{URL: "https://example.org", Valid: true, Status: 200},
},
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.4,
},
{
name: "Broken links",
results: &ContentResults{
Links: []LinkCheck{
{URL: "https://example.com", Valid: true, Status: 404, Error: "Not found"},
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
{
name: "Links with warnings",
results: &ContentResults{
Links: []LinkCheck{
{URL: "https://example.com", Valid: true, Warning: "Could not verify"},
},
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.3,
},
{
name: "No links",
results: &ContentResults{},
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.generateLinkChecks(tt.results)
if tt.name == "No links" {
if len(checks) != 0 {
t.Errorf("Expected no checks, got %d", len(checks))
}
return
}
if len(checks) == 0 {
t.Fatal("Expected at least one check")
}
check := checks[0]
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
})
}
}
func TestGenerateImageChecks(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "All images have alt",
results: &ContentResults{
Images: []ImageCheck{
{Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
{Src: "img2.jpg", HasAlt: true, AltText: "Alt 2"},
},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No images have alt",
results: &ContentResults{
Images: []ImageCheck{
{Src: "img1.jpg", HasAlt: false},
{Src: "img2.jpg", HasAlt: false},
},
},
expectedStatus: api.CheckStatusFail,
},
{
name: "Some images have alt",
results: &ContentResults{
Images: []ImageCheck{
{Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
{Src: "img2.jpg", HasAlt: false},
},
},
expectedStatus: api.CheckStatusWarn,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.generateImageChecks(tt.results)
if len(checks) == 0 {
t.Fatal("Expected at least one check")
}
check := checks[0]
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
})
}
}
func TestGenerateUnsubscribeCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "Has unsubscribe link",
results: &ContentResults{
HasUnsubscribe: true,
UnsubscribeLinks: []string{"https://example.com/unsubscribe"},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No unsubscribe link",
results: &ContentResults{},
expectedStatus: api.CheckStatusWarn,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateUnsubscribeCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
})
}
}
func TestGenerateTextConsistencyCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "High consistency",
results: &ContentResults{
TextPlainRatio: 0.8,
},
expectedStatus: api.CheckStatusPass,
},
{
name: "Low consistency",
results: &ContentResults{
TextPlainRatio: 0.1,
},
expectedStatus: api.CheckStatusWarn,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateTextConsistencyCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateImageRatioCheck(t *testing.T) {
tests := []struct {
name string
results *ContentResults
expectedStatus api.CheckStatus
}{
{
name: "Reasonable ratio",
results: &ContentResults{
ImageTextRatio: 3.0,
Images: []ImageCheck{{}, {}, {}},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "High ratio",
results: &ContentResults{
ImageTextRatio: 7.0,
Images: make([]ImageCheck, 7),
},
expectedStatus: api.CheckStatusWarn,
},
{
name: "Excessive ratio",
results: &ContentResults{
ImageTextRatio: 15.0,
Images: make([]ImageCheck, 15),
},
expectedStatus: api.CheckStatusFail,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateImageRatioCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateSuspiciousURLCheck(t *testing.T) {
results := &ContentResults{
SuspiciousURLs: []string{
"https://bit.ly/abc123",
"https://192.168.1.1/page",
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
check := analyzer.generateSuspiciousURLCheck(results)
if check.Status != api.CheckStatusWarn {
t.Errorf("Status = %v, want %v", check.Status, api.CheckStatusWarn)
}
if check.Category != api.Content {
t.Errorf("Category = %v, want %v", check.Category, api.Content)
}
if !strings.Contains(check.Message, "2") {
t.Error("Message should mention the count of suspicious URLs")
}
}
func TestGetContentScore(t *testing.T) {
tests := []struct {
name string
results *ContentResults
minScore float32
maxScore float32
}{
{
name: "Nil results",
results: nil,
minScore: 0.0,
maxScore: 0.0,
},
{
name: "Perfect content",
results: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: true,
TextPlainRatio: 0.8,
ImageTextRatio: 3.0,
},
minScore: 1.8,
maxScore: 2.0,
},
{
name: "Poor content",
results: &ContentResults{
HTMLValid: false,
Links: []LinkCheck{{Valid: true, Status: 404}},
Images: []ImageCheck{{HasAlt: false}},
HasUnsubscribe: false,
TextPlainRatio: 0.1,
ImageTextRatio: 15.0,
SuspiciousURLs: []string{"url1", "url2"},
},
minScore: 0.0,
maxScore: 0.5,
},
{
name: "Average content",
results: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: false,
TextPlainRatio: 0.5,
ImageTextRatio: 4.0,
},
minScore: 1.0,
maxScore: 1.8,
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := analyzer.GetContentScore(tt.results)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
// Ensure score is capped at 2.0
if score > 2.0 {
t.Errorf("Score %v exceeds maximum of 2.0", score)
}
// Ensure score is not negative
if score < 0.0 {
t.Errorf("Score %v is negative", score)
}
})
}
}
func TestGenerateContentChecks(t *testing.T) {
tests := []struct {
name string
results *ContentResults
minChecks int
}{
{
name: "Nil results",
results: nil,
minChecks: 0,
},
{
name: "Complete results",
results: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: true,
TextContent: "Plain text",
HTMLContent: "<p>HTML text</p>",
ImageTextRatio: 3.0,
},
minChecks: 5, // HTML, Links, Images, Unsubscribe, Text consistency, Image ratio
},
{
name: "With suspicious URLs",
results: &ContentResults{
HTMLValid: true,
SuspiciousURLs: []string{"url1"},
},
minChecks: 3, // HTML, Unsubscribe, Suspicious URLs
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateContentChecks(tt.results)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Content category
for _, check := range checks {
if check.Category != api.Content {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Content)
}
}
})
}
}
// Helper functions for testing
func parseHTML(htmlStr string) (*html.Node, error) {
@ -706,276 +1076,3 @@ func findFirstLink(n *html.Node) *html.Node {
func parseURL(urlStr string) (*url.URL, error) {
return url.Parse(urlStr)
}
func TestHasDomainMisalignment(t *testing.T) {
tests := []struct {
name string
href string
linkText string
expected bool
reason string
}{
// Phishing cases - should return true
{
name: "Obvious phishing - different domains",
href: "https://evil.com/page",
linkText: "Click here to verify your paypal.com account",
expected: true,
reason: "Link text shows 'paypal.com' but URL points to 'evil.com'",
},
{
name: "Domain in link text differs from URL",
href: "http://attacker.net",
linkText: "Visit google.com for more info",
expected: true,
reason: "Link text shows 'google.com' but URL points to 'attacker.net'",
},
{
name: "URL shown in text differs from actual URL",
href: "https://phishing-site.xyz/login",
linkText: "https://www.bank.example.com/secure",
expected: true,
reason: "Full URL in text doesn't match actual destination",
},
{
name: "Similar but different domain",
href: "https://paypa1.com/login",
linkText: "Login to your paypal.com account",
expected: true,
reason: "Typosquatting: 'paypa1.com' vs 'paypal.com'",
},
{
name: "Subdomain spoofing",
href: "https://paypal.com.evil.com/login",
linkText: "Verify your paypal.com account",
expected: true,
reason: "Domain is 'evil.com', not 'paypal.com'",
},
{
name: "Multiple domains in text, none match",
href: "https://badsite.com",
linkText: "Transfer from bank.com to paypal.com",
expected: true,
reason: "Neither 'bank.com' nor 'paypal.com' matches 'badsite.com'",
},
// Legitimate cases - should return false
{
name: "Exact domain match",
href: "https://example.com/page",
linkText: "Visit example.com for more information",
expected: false,
reason: "Domains match exactly",
},
{
name: "Legitimate subdomain",
href: "https://mail.google.com/inbox",
linkText: "Check your google.com email",
expected: false,
reason: "Subdomain of the mentioned domain",
},
{
name: "www prefix variation",
href: "https://www.example.com/page",
linkText: "Visit example.com",
expected: false,
reason: "www prefix is acceptable variation",
},
{
name: "Generic link text - click here",
href: "https://anywhere.com",
linkText: "click here",
expected: false,
reason: "Generic text doesn't contain a domain",
},
{
name: "Generic link text - read more",
href: "https://example.com/article",
linkText: "Read more",
expected: false,
reason: "Generic text doesn't contain a domain",
},
{
name: "Generic link text - learn more",
href: "https://example.com/info",
linkText: "Learn More",
expected: false,
reason: "Generic text doesn't contain a domain (case insensitive)",
},
{
name: "No domain in link text",
href: "https://example.com/page",
linkText: "Click to continue",
expected: false,
reason: "Link text has no domain reference",
},
{
name: "Short link text",
href: "https://example.com",
linkText: "Go",
expected: false,
reason: "Text too short to contain meaningful domain",
},
{
name: "Empty link text",
href: "https://example.com",
linkText: "",
expected: false,
reason: "Empty text cannot contain domain",
},
{
name: "Mailto link - matching domain",
href: "mailto:support@example.com",
linkText: "Email support@example.com",
expected: false,
reason: "Mailto email matches text email",
},
{
name: "Mailto link - domain mismatch (phishing)",
href: "mailto:attacker@evil.com",
linkText: "Contact support@paypal.com for help",
expected: true,
reason: "Mailto domain 'evil.com' doesn't match text domain 'paypal.com'",
},
{
name: "Mailto link - generic text",
href: "mailto:info@example.com",
linkText: "Contact us",
expected: false,
reason: "Generic text without domain reference",
},
{
name: "Mailto link - same domain different user",
href: "mailto:sales@example.com",
linkText: "Email support@example.com",
expected: false,
reason: "Both emails share the same domain",
},
{
name: "Mailto link - text shows only domain",
href: "mailto:info@example.com",
linkText: "Write to example.com",
expected: false,
reason: "Text domain matches mailto domain",
},
{
name: "Mailto link - domain in text doesn't match",
href: "mailto:scam@phishing.net",
linkText: "Reply to customer-service@amazon.com",
expected: true,
reason: "Mailto domain 'phishing.net' doesn't match 'amazon.com' in text",
},
{
name: "Tel link",
href: "tel:+1234567890",
linkText: "Call example.com support",
expected: false,
reason: "Non-HTTP(S) links are excluded",
},
{
name: "Same base domain with different subdomains",
href: "https://www.example.com/page",
linkText: "Visit blog.example.com",
expected: false,
reason: "Both share same base domain 'example.com'",
},
{
name: "URL with path matches domain in text",
href: "https://example.com/section/page",
linkText: "Go to example.com",
expected: false,
reason: "Domain matches, path doesn't matter",
},
{
name: "Generic text - subscribe",
href: "https://newsletter.example.com/signup",
linkText: "Subscribe",
expected: false,
reason: "Generic call-to-action text",
},
{
name: "Generic text - unsubscribe",
href: "https://example.com/unsubscribe?id=123",
linkText: "Unsubscribe",
expected: false,
reason: "Generic unsubscribe text",
},
{
name: "Generic text - download",
href: "https://files.example.com/document.pdf",
linkText: "Download",
expected: false,
reason: "Generic action text",
},
{
name: "Descriptive text without domain",
href: "https://shop.example.com/products",
linkText: "View our latest products",
expected: false,
reason: "No domain mentioned in text",
},
// Edge cases
{
name: "Domain-like text but not valid domain",
href: "https://example.com",
linkText: "Save up to 50.00 dollars",
expected: false,
reason: "50.00 looks like domain but isn't",
},
{
name: "Text with http prefix matching domain",
href: "https://example.com/page",
linkText: "Visit http://example.com",
expected: false,
reason: "Domains match despite different protocols in display",
},
{
name: "Port in URL should not affect matching",
href: "https://example.com:8080/page",
linkText: "Go to example.com",
expected: false,
reason: "Port number doesn't affect domain matching",
},
{
name: "Whitespace in link text",
href: "https://example.com",
linkText: " example.com ",
expected: false,
reason: "Whitespace should be trimmed",
},
{
name: "Multiple spaces in generic text",
href: "https://example.com",
linkText: "click here",
expected: false,
reason: "Generic text with extra spaces",
},
{
name: "Anchor fragment in URL",
href: "https://example.com/page#section",
linkText: "example.com section",
expected: false,
reason: "Fragment doesn't affect domain matching",
},
{
name: "Query parameters in URL",
href: "https://example.com/page?utm_source=email",
linkText: "Visit example.com",
expected: false,
reason: "Query params don't affect domain matching",
},
}
analyzer := NewContentAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.hasDomainMisalignment(tt.href, tt.linkText)
if result != tt.expected {
t.Errorf("hasDomainMisalignment(%q, %q) = %v, want %v\nReason: %s",
tt.href, tt.linkText, result, tt.expected, tt.reason)
}
})
}
}

View file

@ -22,215 +22,698 @@
package analyzer
import (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
// DNSAnalyzer analyzes DNS records for email domains
type DNSAnalyzer struct {
Timeout time.Duration
resolver DNSResolver
resolver *net.Resolver
}
// NewDNSAnalyzer creates a new DNS analyzer with configurable timeout
func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer {
return NewDNSAnalyzerWithResolver(timeout, NewStandardDNSResolver())
}
// NewDNSAnalyzerWithResolver creates a new DNS analyzer with a custom resolver.
// If resolver is nil, a StandardDNSResolver will be used.
func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DNSAnalyzer {
if timeout == 0 {
timeout = 10 * time.Second // Default timeout
}
if resolver == nil {
resolver = NewStandardDNSResolver()
}
return &DNSAnalyzer{
Timeout: timeout,
resolver: resolver,
Timeout: timeout,
resolver: &net.Resolver{
PreferGo: true,
},
}
}
// DNSResults represents DNS validation results for an email
type DNSResults struct {
Domain string
MXRecords []MXRecord
SPFRecord *SPFRecord
DKIMRecords []DKIMRecord
DMARCRecord *DMARCRecord
BIMIRecord *BIMIRecord
Errors []string
}
// MXRecord represents an MX record
type MXRecord struct {
Host string
Priority uint16
Valid bool
Error string
}
// SPFRecord represents an SPF record
type SPFRecord struct {
Record string
Valid bool
Error string
}
// DKIMRecord represents a DKIM record
type DKIMRecord struct {
Selector string
Domain string
Record string
Valid bool
Error string
}
// DMARCRecord represents a DMARC record
type DMARCRecord struct {
Record string
Policy string // none, quarantine, reject
Valid bool
Error string
}
// BIMIRecord represents a BIMI record
type BIMIRecord struct {
Selector string
Domain string
Record string
LogoURL string // URL to the brand logo (SVG)
VMCURL string // URL to Verified Mark Certificate (optional)
Valid bool
Error string
}
// 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) *DNSResults {
// Extract domain from From address
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
return &model.DNSResults{
Errors: &[]string{"Unable to extract domain from email"},
}
}
fromDomain := *headersResults.DomainAlignment.FromDomain
results := &model.DNSResults{
FromDomain: fromDomain,
RpDomain: headersResults.DomainAlignment.ReturnPathDomain,
}
// Determine which domain to check SPF for (Return-Path domain)
// SPF validates the envelope sender (Return-Path), not the From header
spfDomain := fromDomain
if results.RpDomain != nil {
spfDomain = *results.RpDomain
}
// Store sender IP for later use in scoring
var senderIP string
if headersResults.ReceivedChain != nil && len(*headersResults.ReceivedChain) > 0 {
firstHop := (*headersResults.ReceivedChain)[0]
if firstHop.Ip != nil && *firstHop.Ip != "" {
senderIP = *firstHop.Ip
ptrRecords, forwardRecords := d.checkPTRAndForward(senderIP)
if len(ptrRecords) > 0 {
results.PtrRecords = &ptrRecords
}
if len(forwardRecords) > 0 {
results.PtrForwardRecords = &forwardRecords
}
domain := d.extractDomain(email)
if domain == "" {
return &DNSResults{
Errors: []string{"Unable to extract domain from email"},
}
}
// Check MX records for From domain (where replies would go)
results.FromMxRecords = d.checkMXRecords(fromDomain)
// Check MX records for Return-Path domain (where bounces would go)
// Only check if Return-Path domain is different from From domain
if results.RpDomain != nil && *results.RpDomain != fromDomain {
results.RpMxRecords = d.checkMXRecords(*results.RpDomain)
}
// Check SPF records (for Return-Path domain - this is the envelope sender)
// SPF validates the MAIL FROM command, which corresponds to Return-Path
results.SpfRecords = d.checkSPFRecords(spfDomain)
// Check DKIM records by parsing DKIM-Signature headers directly
for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) {
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
if dkimRecord != nil {
if results.DkimRecords == nil {
results.DkimRecords = new([]model.DKIMRecord)
}
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
}
}
// Check DMARC record (for From domain - DMARC protects the visible sender)
// DMARC validates alignment between SPF/DKIM and the From domain
results.DmarcRecord = d.checkDMARCRecord(fromDomain)
// Check BIMI record (for From domain - branding is based on visible sender)
results.BimiRecord = d.checkBIMIRecord(fromDomain, "default")
return results
}
// AnalyzeDomainOnly performs DNS validation for a domain without email context
// This is useful for checking domain configuration without sending an actual email
func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
results := &model.DNSResults{
FromDomain: domain,
results := &DNSResults{
Domain: domain,
}
// Check MX records
results.FromMxRecords = d.checkMXRecords(domain)
results.MXRecords = d.checkMXRecords(domain)
// Check SPF records
results.SpfRecords = d.checkSPFRecords(domain)
// Check SPF record
results.SPFRecord = d.checkSPFRecord(domain)
// Check DKIM records (from authentication results)
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 {
results.DKIMRecords = append(results.DKIMRecords, *dkimRecord)
}
}
}
}
// Check DMARC record
results.DmarcRecord = d.checkDMARCRecord(domain)
results.DMARCRecord = d.checkDMARCRecord(domain)
// Check BIMI record with default selector
results.BimiRecord = d.checkBIMIRecord(domain, "default")
// Check BIMI record (using default selector)
results.BIMIRecord = d.checkBIMIRecord(domain, "default")
return results
}
// CalculateDomainOnlyScore calculates the DNS score for domain-only tests
// Returns a score from 0-100 where higher is better
// This version excludes PTR and DKIM checks since they require email context
func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int, string) {
if results == nil {
return 0, ""
// extractDomain extracts the domain from the email's From address
func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string {
if email.From != nil && email.From.Address != "" {
parts := strings.Split(email.From.Address, "@")
if len(parts) == 2 {
return strings.ToLower(strings.TrimSpace(parts[1]))
}
}
return ""
}
score := 0
// checkMXRecords looks up MX records for a domain
func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
// MX Records: 30 points (only one domain to check)
mxScore := d.calculateMXScore(results)
// Since calculateMXScore checks both From and RP domains,
// and we only have From domain, we use the full score
score += 30 * mxScore / 100
// SPF Records: 30 points
score += 30 * d.calculateSPFScore(results) / 100
// DMARC Record: 40 points
score += 40 * d.calculateDMARCScore(results) / 100
// BIMI Record: only bonus
if results.BimiRecord != nil && results.BimiRecord.Valid {
if score >= 100 {
return 100, "A+"
mxRecords, err := d.resolver.LookupMX(ctx, domain)
if err != nil {
return []MXRecord{
{
Valid: false,
Error: fmt.Sprintf("Failed to lookup MX records: %v", err),
},
}
}
// Ensure score doesn't exceed maximum
if score > 100 {
score = 100
}
// Ensure score is non-negative
if score < 0 {
score = 0
}
return score, ScoreToGradeKind(score)
}
// CalculateDNSScore calculates the DNS score from records results
// Returns a score from 0-100 where higher is better
// senderIP is the original sender IP address used for FCrDNS verification
func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP string) (int, string) {
if results == nil {
return 0, ""
}
score := 0
// PTR and Forward DNS: 20 points
score += 20 * d.calculatePTRScore(results, senderIP) / 100
// MX Records: 20 points (10 for From domain, 10 for Return-Path domain)
score += 20 * d.calculateMXScore(results) / 100
// SPF Records: 20 points
score += 20 * d.calculateSPFScore(results) / 100
// DKIM Records: 20 points
score += 20 * d.calculateDKIMScore(results) / 100
// DMARC Record: 20 points
score += 20 * d.calculateDMARCScore(results) / 100
// BIMI Record
// BIMI is optional but indicates advanced email branding
if results.BimiRecord != nil && results.BimiRecord.Valid {
if score >= 100 {
return 100, "A+"
if len(mxRecords) == 0 {
return []MXRecord{
{
Valid: false,
Error: "No MX records found",
},
}
}
// Ensure score doesn't exceed maximum
if score > 100 {
score = 100
var results []MXRecord
for _, mx := range mxRecords {
results = append(results, MXRecord{
Host: mx.Host,
Priority: mx.Pref,
Valid: true,
})
}
// Ensure score is non-negative
if score < 0 {
score = 0
}
return score, ScoreToGrade(score)
return results
}
// checkSPFRecord looks up and validates SPF record for a domain
func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
if err != nil {
return &SPFRecord{
Valid: false,
Error: fmt.Sprintf("Failed to lookup TXT records: %v", err),
}
}
// Find SPF record (starts with "v=spf1")
var spfRecord string
spfCount := 0
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=spf1") {
spfRecord = txt
spfCount++
}
}
if spfCount == 0 {
return &SPFRecord{
Valid: false,
Error: "No SPF record found",
}
}
if spfCount > 1 {
return &SPFRecord{
Record: spfRecord,
Valid: false,
Error: "Multiple SPF records found (RFC violation)",
}
}
// Basic validation
if !d.validateSPF(spfRecord) {
return &SPFRecord{
Record: spfRecord,
Valid: false,
Error: "SPF record appears malformed",
}
}
return &SPFRecord{
Record: spfRecord,
Valid: true,
}
}
// validateSPF performs basic SPF record validation
func (d *DNSAnalyzer) validateSPF(record string) bool {
// Must start with v=spf1
if !strings.HasPrefix(record, "v=spf1") {
return false
}
// Check for common syntax issues
// Should have a final mechanism (all, +all, -all, ~all, ?all)
validEndings := []string{" all", " +all", " -all", " ~all", " ?all"}
hasValidEnding := false
for _, ending := range validEndings {
if strings.HasSuffix(record, ending) {
hasValidEnding = true
break
}
}
return hasValidEnding
}
// checkDKIMRecord looks up and validates DKIM record for a domain and selector
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord {
// DKIM records are at: selector._domainkey.domain
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
if err != nil {
return &DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err),
}
}
if len(txtRecords) == 0 {
return &DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: "No DKIM record found",
}
}
// Concatenate all TXT record parts (DKIM can be split)
dkimRecord := strings.Join(txtRecords, "")
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
if !d.validateDKIM(dkimRecord) {
return &DKIMRecord{
Selector: selector,
Domain: domain,
Record: dkimRecord,
Valid: false,
Error: "DKIM record appears malformed",
}
}
return &DKIMRecord{
Selector: selector,
Domain: domain,
Record: dkimRecord,
Valid: true,
}
}
// validateDKIM performs basic DKIM record validation
func (d *DNSAnalyzer) validateDKIM(record string) bool {
// Should contain p= tag (public key)
if !strings.Contains(record, "p=") {
return false
}
// 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") {
return false
}
return true
}
// checkDMARCRecord looks up and validates DMARC record for a domain
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord {
// DMARC records are at: _dmarc.domain
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
if err != nil {
return &DMARCRecord{
Valid: false,
Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err),
}
}
// Find DMARC record (starts with "v=DMARC1")
var dmarcRecord string
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
dmarcRecord = txt
break
}
}
if dmarcRecord == "" {
return &DMARCRecord{
Valid: false,
Error: "No DMARC record found",
}
}
// Extract policy
policy := d.extractDMARCPolicy(dmarcRecord)
// Basic validation
if !d.validateDMARC(dmarcRecord) {
return &DMARCRecord{
Record: dmarcRecord,
Policy: policy,
Valid: false,
Error: "DMARC record appears malformed",
}
}
return &DMARCRecord{
Record: dmarcRecord,
Policy: policy,
Valid: true,
}
}
// extractDMARCPolicy extracts the policy from a DMARC record
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
// Look for p=none, p=quarantine, or p=reject
re := regexp.MustCompile(`p=(none|quarantine|reject)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return matches[1]
}
return "unknown"
}
// validateDMARC performs basic DMARC record validation
func (d *DNSAnalyzer) validateDMARC(record string) bool {
// Must start with v=DMARC1
if !strings.HasPrefix(record, "v=DMARC1") {
return false
}
// Must have a policy tag
if !strings.Contains(record, "p=") {
return false
}
return true
}
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord {
// BIMI records are at: selector._bimi.domain
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
if err != nil {
return &BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err),
}
}
if len(txtRecords) == 0 {
return &BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: "No BIMI record found",
}
}
// Concatenate all TXT record parts (BIMI can be split)
bimiRecord := strings.Join(txtRecords, "")
// Extract logo URL and VMC URL
logoURL := d.extractBIMITag(bimiRecord, "l")
vmcURL := d.extractBIMITag(bimiRecord, "a")
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
if !d.validateBIMI(bimiRecord) {
return &BIMIRecord{
Selector: selector,
Domain: domain,
Record: bimiRecord,
LogoURL: logoURL,
VMCURL: vmcURL,
Valid: false,
Error: "BIMI record appears malformed",
}
}
return &BIMIRecord{
Selector: selector,
Domain: domain,
Record: bimiRecord,
LogoURL: logoURL,
VMCURL: vmcURL,
Valid: true,
}
}
// extractBIMITag extracts a tag value from a BIMI record
func (d *DNSAnalyzer) extractBIMITag(record, tag string) string {
// Look for tag=value pattern
re := regexp.MustCompile(tag + `=([^;]+)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return ""
}
// validateBIMI performs basic BIMI record validation
func (d *DNSAnalyzer) validateBIMI(record string) bool {
// Must start with v=BIMI1
if !strings.HasPrefix(record, "v=BIMI1") {
return false
}
// Must have a logo URL tag (l=)
if !strings.Contains(record, "l=") {
return false
}
return true
}
// GenerateDNSChecks generates check results for DNS validation
func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
var checks []api.Check
if results == nil {
return checks
}
// MX record check
checks = append(checks, d.generateMXCheck(results))
// SPF record check
if results.SPFRecord != nil {
checks = append(checks, d.generateSPFCheck(results.SPFRecord))
}
// DKIM record checks
for _, dkim := range results.DKIMRecords {
checks = append(checks, d.generateDKIMCheck(&dkim))
}
// DMARC record check
if results.DMARCRecord != nil {
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
}
// BIMI record check (optional)
if results.BIMIRecord != nil {
checks = append(checks, d.generateBIMICheck(results.BIMIRecord))
}
return checks
}
// generateMXCheck creates a check for MX records
func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
check := api.Check{
Category: api.Dns,
Name: "MX Records",
}
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityCritical)
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
check.Message = results.MXRecords[0].Error
} else {
check.Message = "No valid MX records found"
}
check.Advice = api.PtrTo("Configure MX records for your domain to receive email")
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
// Add details about MX records
var mxList []string
for _, mx := range results.MXRecords {
mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority))
}
details := strings.Join(mxList, ", ")
check.Details = &details
check.Advice = api.PtrTo("Your MX records are properly configured")
}
return check
}
// generateSPFCheck creates a check for SPF records
func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "SPF Record",
}
if !spf.Valid {
// If no record exists at all, it's a failure
if spf.Record == "" {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = spf.Error
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
} else {
// If record exists but is invalid, it's a warning
check.Status = api.CheckStatusWarn
check.Score = 0.5
check.Message = "SPF record found but appears invalid"
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
check.Details = &spf.Record
}
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "Valid SPF record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &spf.Record
check.Advice = api.PtrTo("Your SPF record is properly configured")
}
return check
}
// generateDKIMCheck creates a check for DKIM records
func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector),
}
if !dkim.Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "Valid DKIM record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
check.Details = &details
check.Advice = api.PtrTo("Your DKIM record is properly published")
}
return check
}
// generateDMARCCheck creates a check for DMARC records
func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "DMARC Record",
}
if !dmarc.Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = dmarc.Error
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &dmarc.Record
// Provide advice based on policy
switch dmarc.Policy {
case "none":
advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection"
check.Advice = &advice
case "quarantine":
advice := "DMARC policy is set to 'quarantine'. This provides good protection"
check.Advice = &advice
case "reject":
advice := "DMARC policy is set to 'reject'. This provides the strongest protection"
check.Advice = &advice
default:
advice := "Your DMARC record is properly configured"
check.Advice = &advice
}
}
return check
}
// generateBIMICheck creates a check for BIMI records
func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "BIMI Record",
}
if !bimi.Valid {
// BIMI is optional, so missing record is just informational
if bimi.Record == "" {
check.Status = api.CheckStatusInfo
check.Score = 0.0
check.Message = "No BIMI record found (optional)"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)")
} else {
// If record exists but is invalid
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)")
check.Details = &bimi.Record
}
} else {
check.Status = api.CheckStatusPass
check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
check.Message = "Valid BIMI record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
// Build details with logo and VMC URLs
var detailsParts []string
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector))
if bimi.LogoURL != "" {
detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL))
}
if bimi.VMCURL != "" {
detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL))
check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate")
} else {
check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust")
}
details := strings.Join(detailsParts, ", ")
check.Details = &details
}
return check
}

View file

@ -1,115 +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 (
"context"
"fmt"
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord {
// BIMI records are at: selector._bimi.domain
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
if err != nil {
return &model.BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
}
}
if len(txtRecords) == 0 {
return &model.BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: utils.PtrTo("No BIMI record found"),
}
}
// Concatenate all TXT record parts (BIMI can be split)
bimiRecord := strings.Join(txtRecords, "")
// Extract logo URL and VMC URL
logoURL := d.extractBIMITag(bimiRecord, "l")
vmcURL := d.extractBIMITag(bimiRecord, "a")
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
if !d.validateBIMI(bimiRecord) {
return &model.BIMIRecord{
Selector: selector,
Domain: domain,
Record: &bimiRecord,
LogoUrl: &logoURL,
VmcUrl: &vmcURL,
Valid: false,
Error: utils.PtrTo("BIMI record appears malformed"),
}
}
return &model.BIMIRecord{
Selector: selector,
Domain: domain,
Record: &bimiRecord,
LogoUrl: &logoURL,
VmcUrl: &vmcURL,
Valid: true,
}
}
// extractBIMITag extracts a tag value from a BIMI record
func (d *DNSAnalyzer) extractBIMITag(record, tag string) string {
// Look for tag=value pattern
re := regexp.MustCompile(tag + `=([^;]+)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return ""
}
// validateBIMI performs basic BIMI record validation
func (d *DNSAnalyzer) validateBIMI(record string) bool {
// Must start with v=BIMI1
if !strings.HasPrefix(record, "v=BIMI1") {
return false
}
// Must have a logo URL tag (l=)
if !strings.Contains(record, "l=") {
return false
}
return true
}

View file

@ -1,128 +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 (
"testing"
"time"
)
func TestExtractBIMITag(t *testing.T) {
tests := []struct {
name string
record string
tag string
expectedValue string
}{
{
name: "Extract logo URL (l tag)",
record: "v=BIMI1; l=https://example.com/logo.svg",
tag: "l",
expectedValue: "https://example.com/logo.svg",
},
{
name: "Extract VMC URL (a tag)",
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
tag: "a",
expectedValue: "https://example.com/vmc.pem",
},
{
name: "Tag not found",
record: "v=BIMI1; l=https://example.com/logo.svg",
tag: "a",
expectedValue: "",
},
{
name: "Tag with spaces",
record: "v=BIMI1; l= https://example.com/logo.svg ",
tag: "l",
expectedValue: "https://example.com/logo.svg",
},
{
name: "Empty record",
record: "",
tag: "l",
expectedValue: "",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractBIMITag(tt.record, tt.tag)
if result != tt.expectedValue {
t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue)
}
})
}
}
func TestValidateBIMI(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid BIMI with logo URL",
record: "v=BIMI1; l=https://example.com/logo.svg",
expected: true,
},
{
name: "Valid BIMI with logo and VMC",
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
expected: true,
},
{
name: "Invalid BIMI - no version",
record: "l=https://example.com/logo.svg",
expected: false,
},
{
name: "Invalid BIMI - wrong version",
record: "v=BIMI2; l=https://example.com/logo.svg",
expected: false,
},
{
name: "Invalid BIMI - no logo URL",
record: "v=BIMI1",
expected: false,
},
{
name: "Invalid BIMI - empty",
record: "",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateBIMI(tt.record)
if result != tt.expected {
t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}

View file

@ -1,149 +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 (
"context"
"fmt"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header.
type DKIMHeader struct {
Domain string
Selector string
}
// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values.
func parseDKIMSignatures(signatures []string) []DKIMHeader {
var results []DKIMHeader
for _, sig := range signatures {
var domain, selector string
for _, part := range strings.Split(sig, ";") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
key := strings.TrimSpace(kv[0])
val := strings.TrimSpace(kv[1])
switch key {
case "d":
domain = val
case "s":
selector = val
}
}
if domain != "" && selector != "" {
results = append(results, DKIMHeader{Domain: domain, Selector: selector})
}
}
return results
}
// checkmodel.DKIMRecord looks up and validates DKIM record for a domain and selector
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord {
// DKIM records are at: selector._domainkey.domain
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
if err != nil {
return &model.DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
}
}
if len(txtRecords) == 0 {
return &model.DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: utils.PtrTo("No DKIM record found"),
}
}
// Concatenate all TXT record parts (DKIM can be split)
dkimRecord := strings.Join(txtRecords, "")
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
if !d.validateDKIM(dkimRecord) {
return &model.DKIMRecord{
Selector: selector,
Domain: domain,
Record: utils.PtrTo(dkimRecord),
Valid: false,
Error: utils.PtrTo("DKIM record appears malformed"),
}
}
return &model.DKIMRecord{
Selector: selector,
Domain: domain,
Record: &dkimRecord,
Valid: true,
}
}
// validateDKIM performs basic DKIM record validation
func (d *DNSAnalyzer) validateDKIM(record string) bool {
// Should contain p= tag (public key)
if !strings.Contains(record, "p=") {
return false
}
// 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") {
return false
}
return true
}
func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) {
// DKIM provides strong email authentication
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
hasValidDKIM := false
for _, dkim := range *results.DkimRecords {
if dkim.Valid {
hasValidDKIM = true
break
}
}
if hasValidDKIM {
score += 100
} else {
// Partial credit if DKIM record exists but has issues
score += 25
}
}
return
}

View file

@ -1,286 +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 (
"testing"
"time"
)
func TestParseDKIMSignatures(t *testing.T) {
tests := []struct {
name string
signatures []string
expected []DKIMHeader
}{
{
name: "Empty input",
signatures: nil,
expected: nil,
},
{
name: "Empty string",
signatures: []string{""},
expected: nil,
},
{
name: "Simple Gmail-style",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`,
},
expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}},
},
{
name: "Microsoft 365 style",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`,
},
expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}},
},
{
name: "Tab-folded multiline (Postfix-style)",
signatures: []string{
"v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==",
},
expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}},
},
{
name: "Space-folded multiline (RFC-style)",
signatures: []string{
"v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==",
},
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}},
},
{
name: "d= and s= on separate continuation lines",
signatures: []string{
"v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==",
},
expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}},
},
{
name: "No space after semicolons",
signatures: []string{
`v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`,
},
expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}},
},
{
name: "Multiple spaces after semicolons",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}},
},
{
name: "Ed25519 signature (RFC 8463)",
signatures: []string{
"v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==",
},
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}},
},
{
name: "Multiple signatures (ESP double-signing)",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mydomain.com; s=mail; h=from:to:subject; bh=hash1=; b=sig1==`,
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`,
},
expected: []DKIMHeader{
{Domain: "mydomain.com", Selector: "mail"},
{Domain: "sendib.com", Selector: "mail"},
},
},
{
name: "Dual-algorithm signing (Ed25519 + RSA, same domain, different selectors)",
signatures: []string{
`v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; s=brisbane; h=from:to:subject; bh=hash=; b=edSig==`,
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`,
},
expected: []DKIMHeader{
{Domain: "football.example.com", Selector: "brisbane"},
{Domain: "football.example.com", Selector: "test"},
},
},
{
name: "Amazon SES long selectors",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/simple; d=amazonses.com; s=224i4yxa5dv7c2xz3womw6peuabd; h=from:to:subject; bh=sesHash=; b=sesSig==`,
`v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`,
},
expected: []DKIMHeader{
{Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"},
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"},
},
},
{
name: "Subdomain in d=",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}},
},
{
name: "Deeply nested subdomain",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}},
},
{
name: "Selector with hyphens (Microsoft 365 custom domain style)",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}},
},
{
name: "Selector with dots",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}},
},
{
name: "Single-character selector",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}},
},
{
name: "Postmark-style timestamp selector, s= before d=",
signatures: []string{
`v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`,
},
expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}},
},
{
name: "d= and s= at the very end",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`,
},
expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}},
},
{
name: "Full tag set",
signatures: []string{
`v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`,
},
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}},
},
{
name: "Missing d= tag",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=selector1; h=from:to; bh=hash=; b=sig==`,
},
expected: nil,
},
{
name: "Missing s= tag",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; h=from:to; bh=hash=; b=sig==`,
},
expected: nil,
},
{
name: "Missing both d= and s= tags",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to; bh=hash=; b=sig==`,
},
expected: nil,
},
{
name: "Mix of valid and invalid signatures",
signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=good.com; s=sel1; h=from:to; bh=hash=; b=sig==`,
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=orphan; h=from:to; bh=hash=; b=sig==`,
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`,
},
expected: []DKIMHeader{
{Domain: "good.com", Selector: "sel1"},
{Domain: "also-good.com", Selector: "sel2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseDKIMSignatures(tt.signatures)
if len(result) != len(tt.expected) {
t.Fatalf("parseDKIMSignatures() returned %d results, want %d\n got: %+v\n want: %+v", len(result), len(tt.expected), result, tt.expected)
}
for i := range tt.expected {
if result[i].Domain != tt.expected[i].Domain {
t.Errorf("result[%d].Domain = %q, want %q", i, result[i].Domain, tt.expected[i].Domain)
}
if result[i].Selector != tt.expected[i].Selector {
t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector)
}
}
})
}
}
func TestValidateDKIM(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid DKIM with version",
record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: true,
},
{
name: "Valid DKIM without version",
record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: true,
},
{
name: "Invalid DKIM - no public key",
record: "v=DKIM1; k=rsa",
expected: false,
},
{
name: "Invalid DKIM - wrong version",
record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: false,
},
{
name: "Invalid DKIM - empty",
record: "",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateDKIM(tt.record)
if result != tt.expected {
t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}

View file

@ -1,257 +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 (
"context"
"fmt"
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// checkmodel.DMARCRecord looks up and validates DMARC record for a domain
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
// DMARC records are at: _dmarc.domain
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
if err != nil {
return &model.DMARCRecord{
Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
}
}
// Find DMARC record (starts with "v=DMARC1")
var dmarcRecord string
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
dmarcRecord = txt
break
}
}
if dmarcRecord == "" {
return &model.DMARCRecord{
Valid: false,
Error: utils.PtrTo("No DMARC record found"),
}
}
// 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 &model.DMARCRecord{
Record: &dmarcRecord,
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
SubdomainPolicy: subdomainPolicy,
Percentage: percentage,
SpfAlignment: spfAlignment,
DkimAlignment: dkimAlignment,
Valid: false,
Error: utils.PtrTo("DMARC record appears malformed"),
}
}
return &model.DMARCRecord{
Record: &dmarcRecord,
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
SubdomainPolicy: subdomainPolicy,
Percentage: percentage,
SpfAlignment: spfAlignment,
DkimAlignment: dkimAlignment,
Valid: true,
}
}
// extractDMARCPolicy extracts the policy from a DMARC record
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
// Look for p=none, p=quarantine, or p=reject
re := regexp.MustCompile(`p=(none|quarantine|reject)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return matches[1]
}
return "unknown"
}
// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record
// Returns "relaxed" (default) or "strict"
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *model.DMARCRecordSpfAlignment {
// Look for aspf=s (strict) or aspf=r (relaxed)
re := regexp.MustCompile(`aspf=(r|s)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
if matches[1] == "s" {
return utils.PtrTo(model.DMARCRecordSpfAlignmentStrict)
}
return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
}
// Default is relaxed if not specified
return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
}
// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record
// Returns "relaxed" (default) or "strict"
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *model.DMARCRecordDkimAlignment {
// Look for adkim=s (strict) or adkim=r (relaxed)
re := regexp.MustCompile(`adkim=(r|s)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
if matches[1] == "s" {
return utils.PtrTo(model.DMARCRecordDkimAlignmentStrict)
}
return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
}
// Default is relaxed if not specified
return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
}
// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record
// Returns the sp tag value or nil if not specified (defaults to main policy)
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *model.DMARCRecordSubdomainPolicy {
// Look for sp=none, sp=quarantine, or sp=reject
re := regexp.MustCompile(`sp=(none|quarantine|reject)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return utils.PtrTo(model.DMARCRecordSubdomainPolicy(matches[1]))
}
// 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 {
// Must start with v=DMARC1
if !strings.HasPrefix(record, "v=DMARC1") {
return false
}
// Must have a policy tag
if !strings.Contains(record, "p=") {
return false
}
return true
}
func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) {
// DMARC ties SPF and DKIM together and provides policy
if results.DmarcRecord != nil {
if results.DmarcRecord.Valid {
score += 50
// Bonus points for stricter policies
if results.DmarcRecord.Policy != nil {
switch *results.DmarcRecord.Policy {
case "reject":
// Strictest policy - full points already awarded
score += 25
case "quarantine":
// Good policy - no deduction
case "none":
// Weakest policy - deduct 5 points
score -= 25
}
}
// Bonus points for strict alignment modes (2 points each)
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict {
score += 5
}
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
score += 5
}
// Subdomain policy scoring (sp tag)
// +3 for stricter or equal subdomain policy, -3 for weaker
if results.DmarcRecord.SubdomainPolicy != nil {
mainPolicy := string(*results.DmarcRecord.Policy)
subPolicy := string(*results.DmarcRecord.SubdomainPolicy)
// 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
} else {
// Subdomain policy is weaker
score -= 15
}
} else {
// No sp tag means subdomains inherit main policy (good default)
score += 15
}
// Percentage scoring (pct tag)
// Apply the percentage on the current score
if results.DmarcRecord.Percentage != nil {
pct := *results.DmarcRecord.Percentage
score = score * pct / 100
}
} else if results.DmarcRecord.Record != nil {
// Partial credit if DMARC record exists but has issues
score += 20
}
}
return
}

View file

@ -1,344 +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 (
"testing"
"time"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
func TestExtractDMARCPolicy(t *testing.T) {
tests := []struct {
name string
record string
expectedPolicy string
}{
{
name: "Policy none",
record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com",
expectedPolicy: "none",
},
{
name: "Policy quarantine",
record: "v=DMARC1; p=quarantine; pct=100",
expectedPolicy: "quarantine",
},
{
name: "Policy reject",
record: "v=DMARC1; p=reject; sp=reject",
expectedPolicy: "reject",
},
{
name: "No policy",
record: "v=DMARC1",
expectedPolicy: "unknown",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCPolicy(tt.record)
if result != tt.expectedPolicy {
t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy)
}
})
}
}
func TestValidateDMARC(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid DMARC",
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
expected: true,
},
{
name: "Valid DMARC minimal",
record: "v=DMARC1; p=none",
expected: true,
},
{
name: "Invalid DMARC - no version",
record: "p=quarantine",
expected: false,
},
{
name: "Invalid DMARC - no policy",
record: "v=DMARC1",
expected: false,
},
{
name: "Invalid DMARC - wrong version",
record: "v=DMARC2; p=reject",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateDMARC(tt.record)
if result != tt.expected {
t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestExtractDMARCSPFAlignment(t *testing.T) {
tests := []struct {
name string
record string
expectedAlignment string
}{
{
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",
expectedAlignment: "strict",
},
{
name: "Both alignments specified - check SPF relaxed",
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
expectedAlignment: "relaxed",
},
{
name: "Complex record with SPF strict",
record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100",
expectedAlignment: "strict",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCSPFAlignment(tt.record)
if result == nil {
t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record)
}
if string(*result) != tt.expectedAlignment {
t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment)
}
})
}
}
func TestExtractDMARCDKIMAlignment(t *testing.T) {
tests := []struct {
name string
record string
expectedAlignment string
}{
{
name: "DKIM alignment - strict",
record: "v=DMARC1; p=reject; adkim=s",
expectedAlignment: "strict",
},
{
name: "DKIM alignment - relaxed (explicit)",
record: "v=DMARC1; p=reject; adkim=r",
expectedAlignment: "relaxed",
},
{
name: "DKIM alignment - relaxed (default, not specified)",
record: "v=DMARC1; p=none",
expectedAlignment: "relaxed",
},
{
name: "Both alignments specified - check DKIM strict",
record: "v=DMARC1; p=quarantine; aspf=r; adkim=s",
expectedAlignment: "strict",
},
{
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",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCDKIMAlignment(tt.record)
if result == nil {
t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record)
}
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: utils.PtrTo("none"),
},
{
name: "Subdomain policy - quarantine",
record: "v=DMARC1; p=reject; sp=quarantine",
expectedPolicy: utils.PtrTo("quarantine"),
},
{
name: "Subdomain policy - reject",
record: "v=DMARC1; p=quarantine; sp=reject",
expectedPolicy: utils.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: utils.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 {
if result == nil {
t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy)
}
if string(*result) != *tt.expectedPolicy {
t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy)
}
}
})
}
}
func TestExtractDMARCPercentage(t *testing.T) {
tests := []struct {
name string
record string
expectedPercentage *int
}{
{
name: "Percentage - 100",
record: "v=DMARC1; p=quarantine; pct=100",
expectedPercentage: utils.PtrTo(100),
},
{
name: "Percentage - 50",
record: "v=DMARC1; p=quarantine; pct=50",
expectedPercentage: utils.PtrTo(50),
},
{
name: "Percentage - 25",
record: "v=DMARC1; p=reject; pct=25",
expectedPercentage: utils.PtrTo(25),
},
{
name: "Percentage - 0",
record: "v=DMARC1; p=none; pct=0",
expectedPercentage: utils.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: utils.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)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCPercentage(tt.record)
if tt.expectedPercentage == nil {
if result != nil {
t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result)
}
} else {
if result == nil {
t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage)
}
if *result != *tt.expectedPercentage {
t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage)
}
}
})
}
}

View file

@ -1,94 +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 (
"context"
"git.happydns.org/happyDeliver/internal/model"
)
// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA)
// Returns PTR hostnames and their corresponding forward-resolved IPs
func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
// Perform reverse DNS lookup (PTR)
ptrNames, err := d.resolver.LookupAddr(ctx, ip)
if err != nil || len(ptrNames) == 0 {
return nil, nil
}
var forwardIPs []string
seenIPs := make(map[string]bool)
// For each PTR record, perform forward DNS lookup (A/AAAA)
for _, ptrName := range ptrNames {
// Look up A records
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
aRecords, err := d.resolver.LookupHost(ctx, ptrName)
cancel()
if err == nil {
for _, forwardIP := range aRecords {
if !seenIPs[forwardIP] {
forwardIPs = append(forwardIPs, forwardIP)
seenIPs[forwardIP] = true
}
}
}
}
return ptrNames, forwardIPs
}
// 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) {
if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
// 50 points for having PTR records
score += 50
if len(*results.PtrRecords) > 1 {
// Penalty has it's bad to have multiple PTR records
score -= 15
}
// Additional 50 points for forward-confirmed reverse DNS (FCrDNS)
// This means the PTR hostname resolves back to IPs that include the original sender IP
if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" {
// Verify that the sender IP is in the list of forward-resolved IPs
fcrDnsValid := false
for _, forwardIP := range *results.PtrForwardRecords {
if forwardIP == senderIP {
fcrDnsValid = true
break
}
}
if fcrDnsValid {
score += 50
}
}
}
return
}

View file

@ -1,116 +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 (
"context"
"fmt"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// checkMXRecords looks up MX records for a domain
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]model.MXRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
mxRecords, err := d.resolver.LookupMX(ctx, domain)
if err != nil {
return &[]model.MXRecord{
{
Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
},
}
}
if len(mxRecords) == 0 {
return &[]model.MXRecord{
{
Valid: false,
Error: utils.PtrTo("No MX records found"),
},
}
}
var results []model.MXRecord
for _, mx := range mxRecords {
results = append(results, model.MXRecord{
Host: mx.Host,
Priority: mx.Pref,
Valid: true,
})
}
return &results
}
func (d *DNSAnalyzer) calculateMXScore(results *model.DNSResults) (score int) {
// Having valid MX records is critical for email deliverability
// From domain MX records (half points) - needed for replies
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
hasValidFromMX := false
for _, mx := range *results.FromMxRecords {
if mx.Valid {
hasValidFromMX = true
break
}
}
if hasValidFromMX {
score += 50
}
}
// Return-Path domain MX records (10 points) - needed for bounces
if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 {
hasValidRpMX := false
for _, mx := range *results.RpMxRecords {
if mx.Valid {
hasValidRpMX = true
break
}
}
if hasValidRpMX {
score += 50
}
} else if results.RpDomain != nil && *results.RpDomain != results.FromDomain {
// If Return-Path domain is different but has no MX records, it's a problem
// Don't deduct points if RP domain is same as From domain (already checked)
} else {
// If Return-Path is same as From domain, give full 10 points for RP MX
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
hasValidFromMX := false
for _, mx := range *results.FromMxRecords {
if mx.Valid {
hasValidFromMX = true
break
}
}
if hasValidFromMX {
score += 50
}
}
}
return
}

View file

@ -1,80 +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 (
"context"
"net"
)
// DNSResolver defines the interface for DNS resolution operations.
// This interface abstracts DNS lookups to allow for custom implementations,
// such as mock resolvers for testing or caching resolvers for performance.
type DNSResolver interface {
// LookupMX returns the DNS MX records for the given domain.
LookupMX(ctx context.Context, name string) ([]*net.MX, error)
// LookupTXT returns the DNS TXT records for the given domain.
LookupTXT(ctx context.Context, name string) ([]string, error)
// LookupAddr performs a reverse lookup for the given IP address,
// returning a list of hostnames mapping to that address.
LookupAddr(ctx context.Context, addr string) ([]string, error)
// LookupHost looks up the given hostname using the local resolver.
// It returns a slice of that host's addresses (IPv4 and IPv6).
LookupHost(ctx context.Context, host string) ([]string, error)
}
// StandardDNSResolver is the default DNS resolver implementation that uses net.Resolver.
type StandardDNSResolver struct {
resolver *net.Resolver
}
// NewStandardDNSResolver creates a new StandardDNSResolver with default settings.
func NewStandardDNSResolver() DNSResolver {
return &StandardDNSResolver{
resolver: &net.Resolver{
PreferGo: true,
},
}
}
// LookupMX implements DNSResolver.LookupMX using net.Resolver.
func (r *StandardDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
return r.resolver.LookupMX(ctx, name)
}
// LookupTXT implements DNSResolver.LookupTXT using net.Resolver.
func (r *StandardDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
return r.resolver.LookupTXT(ctx, name)
}
// LookupAddr implements DNSResolver.LookupAddr using net.Resolver.
func (r *StandardDNSResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
return r.resolver.LookupAddr(ctx, addr)
}
// LookupHost implements DNSResolver.LookupHost using net.Resolver.
func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
return r.resolver.LookupHost(ctx, host)
}

View file

@ -1,368 +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 (
"context"
"fmt"
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]model.SPFRecord {
visited := make(map[string]bool)
return d.resolveSPFRecords(domain, visited, 0, true)
}
// resolveSPFRecords recursively resolves SPF records including include: directives
// isMainRecord indicates if this is the primary domain's record (not an included one)
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]model.SPFRecord {
const maxDepth = 10 // Prevent infinite recursion
if depth > maxDepth {
return &[]model.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: utils.PtrTo("Maximum SPF include depth exceeded"),
},
}
}
// Prevent circular references
if visited[domain] {
return &[]model.SPFRecord{}
}
visited[domain] = true
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
if err != nil {
return &[]model.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
},
}
}
// Find SPF record (starts with "v=spf1")
var spfRecord string
spfCount := 0
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=spf1") {
spfRecord = txt
spfCount++
}
}
if spfCount == 0 {
return &[]model.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: utils.PtrTo("No SPF record found"),
},
}
}
var results []model.SPFRecord
if spfCount > 1 {
results = append(results, model.SPFRecord{
Domain: &domain,
Record: &spfRecord,
Valid: false,
Error: utils.PtrTo("Multiple SPF records found (RFC violation)"),
})
return &results
}
// Basic validation
validationErr := d.validateSPF(spfRecord, isMainRecord)
// Extract the "all" mechanism qualifier
var allQualifier *model.SPFRecordAllQualifier
var errMsg *string
if validationErr != nil {
errMsg = utils.PtrTo(validationErr.Error())
} else {
// Extract qualifier from the "all" mechanism
if strings.HasSuffix(spfRecord, " -all") {
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("-"))
} else if strings.HasSuffix(spfRecord, " ~all") {
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("~"))
} else if strings.HasSuffix(spfRecord, " +all") {
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
} else if strings.HasSuffix(spfRecord, " ?all") {
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("?"))
} else if strings.HasSuffix(spfRecord, " all") {
// Implicit + qualifier (default)
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
}
}
results = append(results, model.SPFRecord{
Domain: &domain,
Record: &spfRecord,
Valid: validationErr == nil,
AllQualifier: allQualifier,
Error: errMsg,
})
// Check for redirect= modifier first (it replaces the entire SPF policy)
redirectDomain := d.extractSPFRedirect(spfRecord)
if redirectDomain != "" {
// redirect= replaces the current domain's policy entirely
// Only follow if no other mechanisms matched (per RFC 7208)
redirectRecords := d.resolveSPFRecords(redirectDomain, visited, depth+1, false)
if redirectRecords != nil {
results = append(results, *redirectRecords...)
}
return &results
}
// Extract and resolve include: directives
includes := d.extractSPFIncludes(spfRecord)
for _, includeDomain := range includes {
includedRecords := d.resolveSPFRecords(includeDomain, visited, depth+1, false)
if includedRecords != nil {
results = append(results, *includedRecords...)
}
}
return &results
}
// extractSPFIncludes extracts all include: domains from an SPF record
func (d *DNSAnalyzer) extractSPFIncludes(record string) []string {
var includes []string
re := regexp.MustCompile(`include:([^\s]+)`)
matches := re.FindAllStringSubmatch(record, -1)
for _, match := range matches {
if len(match) > 1 {
includes = append(includes, match[1])
}
}
return includes
}
// extractSPFRedirect extracts the redirect= domain from an SPF record
// The redirect= modifier replaces the current domain's SPF policy with that of the target domain
func (d *DNSAnalyzer) extractSPFRedirect(record string) string {
re := regexp.MustCompile(`redirect=([^\s]+)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return matches[1]
}
return ""
}
// isValidSPFMechanism checks if a token is a valid SPF mechanism or modifier
func (d *DNSAnalyzer) isValidSPFMechanism(token string) error {
// Remove qualifier prefix if present (+, -, ~, ?)
mechanism := strings.TrimLeft(token, "+-~?")
// Check if it's a modifier (contains =)
if strings.Contains(mechanism, "=") {
// Allow known modifiers: redirect=, exp=, and RFC 6652 modifiers (ra=, rp=, rr=)
if strings.HasPrefix(mechanism, "redirect=") ||
strings.HasPrefix(mechanism, "exp=") ||
strings.HasPrefix(mechanism, "ra=") ||
strings.HasPrefix(mechanism, "rp=") ||
strings.HasPrefix(mechanism, "rr=") {
return nil
}
// Check if it's a common mistake (using = instead of :)
parts := strings.SplitN(mechanism, "=", 2)
if len(parts) == 2 {
mechanismName := parts[0]
knownMechanisms := []string{"include", "a", "mx", "ptr", "exists"}
for _, known := range knownMechanisms {
if mechanismName == known {
return fmt.Errorf("invalid syntax '%s': mechanism '%s' should use ':' not '='", token, mechanismName)
}
}
}
return fmt.Errorf("unknown modifier '%s'", token)
}
// Check standalone mechanisms (no domain/value required)
if mechanism == "all" || mechanism == "a" || mechanism == "mx" || mechanism == "ptr" {
return nil
}
// Check mechanisms with domain/value
knownPrefixes := []string{
"include:",
"a:", "a/",
"mx:", "mx/",
"ptr:",
"ip4:",
"ip6:",
"exists:",
}
for _, prefix := range knownPrefixes {
if strings.HasPrefix(mechanism, prefix) {
return nil
}
}
return fmt.Errorf("unknown mechanism '%s'", token)
}
// validateSPF performs basic SPF record validation
// isMainRecord indicates if this is the primary domain's record (not an included one)
func (d *DNSAnalyzer) validateSPF(record string, isMainRecord bool) error {
// Must start with v=spf1
if !strings.HasPrefix(record, "v=spf1") {
return fmt.Errorf("SPF record must start with 'v=spf1'")
}
// Parse and validate each token in the SPF record
tokens := strings.Fields(record)
hasRedirect := false
for i, token := range tokens {
// Skip the version tag
if i == 0 && token == "v=spf1" {
continue
}
// Check if it's a valid mechanism
if err := d.isValidSPFMechanism(token); err != nil {
return err
}
// Track if we have a redirect modifier
mechanism := strings.TrimLeft(token, "+-~?")
if strings.HasPrefix(mechanism, "redirect=") {
hasRedirect = true
}
}
// Check for redirect= modifier (which replaces the need for an 'all' mechanism)
if hasRedirect {
return nil
}
// Only check for 'all' mechanism on the main record, not on included records
if isMainRecord {
// Check for common syntax issues
// Should have a final mechanism (all, +all, -all, ~all, ?all)
validEndings := []string{" all", " +all", " -all", " ~all", " ?all"}
hasValidEnding := false
for _, ending := range validEndings {
if strings.HasSuffix(record, ending) {
hasValidEnding = true
break
}
}
if !hasValidEnding {
return fmt.Errorf("SPF record should end with an 'all' mechanism (e.g., '-all', '~all') or have a 'redirect=' modifier")
}
}
return nil
}
// hasSPFStrictFail checks if SPF record has strict -all mechanism
func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool {
return strings.HasSuffix(record, " -all")
}
func (d *DNSAnalyzer) calculateSPFScore(results *model.DNSResults) (score int) {
// SPF is essential for email authentication
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
// Find the main SPF record by skipping redirects
// Loop through records to find the last redirect or the first non-redirect
mainSPFIndex := 0
for i := 0; i < len(*results.SpfRecords); i++ {
spfRecord := (*results.SpfRecords)[i]
if spfRecord.Record != nil && strings.Contains(*spfRecord.Record, "redirect=") {
// This is a redirect, check if there's a next record
if i+1 < len(*results.SpfRecords) {
mainSPFIndex = i + 1
} else {
// Redirect exists but no target record found
break
}
} else {
// Found a non-redirect record
mainSPFIndex = i
break
}
}
mainSPF := (*results.SpfRecords)[mainSPFIndex]
if mainSPF.Valid {
// Full points for valid SPF
score += 75
// Check if DMARC is configured with strict policy as all mechanism is less significant
dmarcStrict := results.DmarcRecord != nil &&
results.DmarcRecord.Valid && results.DmarcRecord.Policy != nil &&
(*results.DmarcRecord.Policy == "quarantine" ||
*results.DmarcRecord.Policy == "reject")
// Deduct points based on the all mechanism qualifier
if mainSPF.AllQualifier != nil {
switch *mainSPF.AllQualifier {
case "-":
// Strict fail - no deduction, this is the recommended policy
score += 25
case "~":
// Softfail - if DMARC is quarantine or reject, treat it mostly like strict fail
if dmarcStrict {
score += 20
}
// Otherwise, moderate penalty (no points added or deducted)
case "+", "?":
// Pass/neutral - severe penalty
if !dmarcStrict {
score -= 25
}
}
} else {
// No 'all' mechanism qualifier extracted - severe penalty
score -= 25
}
} else if mainSPF.Record != nil {
// Partial credit if SPF record exists but has issues
score += 25
}
}
return
}

View file

@ -1,284 +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"
"time"
)
func TestValidateSPF(t *testing.T) {
tests := []struct {
name string
record string
expectError bool
errorMsg string // Expected error message (substring match)
}{
{
name: "Valid SPF with -all",
record: "v=spf1 include:_spf.example.com -all",
expectError: false,
},
{
name: "Valid SPF with ~all",
record: "v=spf1 ip4:192.0.2.0/24 ~all",
expectError: false,
},
{
name: "Valid SPF with +all",
record: "v=spf1 +all",
expectError: false,
},
{
name: "Valid SPF with ?all",
record: "v=spf1 mx ?all",
expectError: false,
},
{
name: "Valid SPF with redirect",
record: "v=spf1 redirect=_spf.example.com",
expectError: false,
},
{
name: "Valid SPF with redirect and mechanisms",
record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.example.com",
expectError: false,
},
{
name: "Valid SPF with multiple mechanisms",
record: "v=spf1 a mx ip4:192.0.2.0/24 include:_spf.example.com -all",
expectError: false,
},
{
name: "Valid SPF with exp modifier",
record: "v=spf1 mx exp=explain.example.com -all",
expectError: false,
},
{
name: "Invalid SPF - no version",
record: "include:_spf.example.com -all",
expectError: true,
errorMsg: "must start with 'v=spf1'",
},
{
name: "Invalid SPF - no all mechanism or redirect",
record: "v=spf1 include:_spf.example.com",
expectError: true,
errorMsg: "should end with an 'all' mechanism",
},
{
name: "Invalid SPF - wrong version",
record: "v=spf2 include:_spf.example.com -all",
expectError: true,
errorMsg: "must start with 'v=spf1'",
},
{
name: "Invalid SPF - include= instead of include:",
record: "v=spf1 include=icloud.com ~all",
expectError: true,
errorMsg: "should use ':' not '='",
},
{
name: "Invalid SPF - a= instead of a:",
record: "v=spf1 a=example.com -all",
expectError: true,
errorMsg: "should use ':' not '='",
},
{
name: "Invalid SPF - mx= instead of mx:",
record: "v=spf1 mx=example.com -all",
expectError: true,
errorMsg: "should use ':' not '='",
},
{
name: "Invalid SPF - unknown mechanism",
record: "v=spf1 foobar -all",
expectError: true,
errorMsg: "unknown mechanism",
},
{
name: "Invalid SPF - unknown modifier",
record: "v=spf1 -all unknown=value",
expectError: true,
errorMsg: "unknown modifier",
},
{
name: "Valid SPF with RFC 6652 ra modifier",
record: "v=spf1 mx ra=postmaster -all",
expectError: false,
},
{
name: "Valid SPF with RFC 6652 rp modifier",
record: "v=spf1 mx rp=100 -all",
expectError: false,
},
{
name: "Valid SPF with RFC 6652 rr modifier",
record: "v=spf1 mx rr=all -all",
expectError: false,
},
{
name: "Valid SPF with all RFC 6652 modifiers",
record: "v=spf1 mx ra=postmaster rp=50 rr=fail -all",
expectError: false,
},
{
name: "Valid SPF with RFC 6652 modifiers and redirect",
record: "v=spf1 ip4:192.0.2.0/24 ra=abuse redirect=_spf.example.com",
expectError: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test as main record (isMainRecord = true) since these tests check overall SPF validity
err := analyzer.validateSPF(tt.record, true)
if tt.expectError {
if err == nil {
t.Errorf("validateSPF(%q) expected error but got nil", tt.record)
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("validateSPF(%q) error = %q, want error containing %q", tt.record, err.Error(), tt.errorMsg)
}
} else {
if err != nil {
t.Errorf("validateSPF(%q) unexpected error: %v", tt.record, err)
}
}
})
}
}
func TestValidateSPF_IncludedRecords(t *testing.T) {
tests := []struct {
name string
record string
isMainRecord bool
expectError bool
errorMsg string
}{
{
name: "Main record without 'all' - should error",
record: "v=spf1 include:_spf.example.com",
isMainRecord: true,
expectError: true,
errorMsg: "should end with an 'all' mechanism",
},
{
name: "Included record without 'all' - should NOT error",
record: "v=spf1 include:_spf.example.com",
isMainRecord: false,
expectError: false,
},
{
name: "Included record with only mechanisms - should NOT error",
record: "v=spf1 ip4:192.0.2.0/24 mx",
isMainRecord: false,
expectError: false,
},
{
name: "Main record with only mechanisms - should error",
record: "v=spf1 ip4:192.0.2.0/24 mx",
isMainRecord: true,
expectError: true,
errorMsg: "should end with an 'all' mechanism",
},
{
name: "Included record with 'all' - valid",
record: "v=spf1 ip4:192.0.2.0/24 -all",
isMainRecord: false,
expectError: false,
},
{
name: "Main record with 'all' - valid",
record: "v=spf1 ip4:192.0.2.0/24 -all",
isMainRecord: true,
expectError: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := analyzer.validateSPF(tt.record, tt.isMainRecord)
if tt.expectError {
if err == nil {
t.Errorf("validateSPF(%q, isMainRecord=%v) expected error but got nil", tt.record, tt.isMainRecord)
} else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("validateSPF(%q, isMainRecord=%v) error = %q, want error containing %q", tt.record, tt.isMainRecord, err.Error(), tt.errorMsg)
}
} else {
if err != nil {
t.Errorf("validateSPF(%q, isMainRecord=%v) unexpected error: %v", tt.record, tt.isMainRecord, err)
}
}
})
}
}
func TestExtractSPFRedirect(t *testing.T) {
tests := []struct {
name string
record string
expectedRedirect string
}{
{
name: "SPF with redirect",
record: "v=spf1 redirect=_spf.example.com",
expectedRedirect: "_spf.example.com",
},
{
name: "SPF with redirect and other mechanisms",
record: "v=spf1 ip4:192.0.2.0/24 redirect=_spf.google.com",
expectedRedirect: "_spf.google.com",
},
{
name: "SPF without redirect",
record: "v=spf1 include:_spf.example.com -all",
expectedRedirect: "",
},
{
name: "SPF with only all mechanism",
record: "v=spf1 -all",
expectedRedirect: "",
},
{
name: "Empty record",
record: "",
expectedRedirect: "",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractSPFRedirect(tt.record)
if result != tt.expectedRedirect {
t.Errorf("extractSPFRedirect(%q) = %q, want %q", tt.record, result, tt.expectedRedirect)
}
})
}
}

View file

@ -22,8 +22,12 @@
package analyzer
import (
"net/mail"
"strings"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewDNSAnalyzer(t *testing.T) {
@ -56,3 +60,761 @@ func TestNewDNSAnalyzer(t *testing.T) {
})
}
}
func TestExtractDomain(t *testing.T) {
tests := []struct {
name string
fromAddress string
expectedDomain string
}{
{
name: "Valid email",
fromAddress: "user@example.com",
expectedDomain: "example.com",
},
{
name: "Email with subdomain",
fromAddress: "user@mail.example.com",
expectedDomain: "mail.example.com",
},
{
name: "Email with uppercase",
fromAddress: "User@Example.COM",
expectedDomain: "example.com",
},
{
name: "Invalid email (no @)",
fromAddress: "invalid-email",
expectedDomain: "",
},
{
name: "Empty email",
fromAddress: "",
expectedDomain: "",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: make(mail.Header),
}
if tt.fromAddress != "" {
email.From = &mail.Address{
Address: tt.fromAddress,
}
}
domain := analyzer.extractDomain(email)
if domain != tt.expectedDomain {
t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain)
}
})
}
}
func TestValidateSPF(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid SPF with -all",
record: "v=spf1 include:_spf.example.com -all",
expected: true,
},
{
name: "Valid SPF with ~all",
record: "v=spf1 ip4:192.0.2.0/24 ~all",
expected: true,
},
{
name: "Valid SPF with +all",
record: "v=spf1 +all",
expected: true,
},
{
name: "Valid SPF with ?all",
record: "v=spf1 mx ?all",
expected: true,
},
{
name: "Invalid SPF - no version",
record: "include:_spf.example.com -all",
expected: false,
},
{
name: "Invalid SPF - no all mechanism",
record: "v=spf1 include:_spf.example.com",
expected: false,
},
{
name: "Invalid SPF - wrong version",
record: "v=spf2 include:_spf.example.com -all",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateSPF(tt.record)
if result != tt.expected {
t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestValidateDKIM(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid DKIM with version",
record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: true,
},
{
name: "Valid DKIM without version",
record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: true,
},
{
name: "Invalid DKIM - no public key",
record: "v=DKIM1; k=rsa",
expected: false,
},
{
name: "Invalid DKIM - wrong version",
record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: false,
},
{
name: "Invalid DKIM - empty",
record: "",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateDKIM(tt.record)
if result != tt.expected {
t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestExtractDMARCPolicy(t *testing.T) {
tests := []struct {
name string
record string
expectedPolicy string
}{
{
name: "Policy none",
record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com",
expectedPolicy: "none",
},
{
name: "Policy quarantine",
record: "v=DMARC1; p=quarantine; pct=100",
expectedPolicy: "quarantine",
},
{
name: "Policy reject",
record: "v=DMARC1; p=reject; sp=reject",
expectedPolicy: "reject",
},
{
name: "No policy",
record: "v=DMARC1",
expectedPolicy: "unknown",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCPolicy(tt.record)
if result != tt.expectedPolicy {
t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy)
}
})
}
}
func TestValidateDMARC(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid DMARC",
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
expected: true,
},
{
name: "Valid DMARC minimal",
record: "v=DMARC1; p=none",
expected: true,
},
{
name: "Invalid DMARC - no version",
record: "p=quarantine",
expected: false,
},
{
name: "Invalid DMARC - no policy",
record: "v=DMARC1",
expected: false,
},
{
name: "Invalid DMARC - wrong version",
record: "v=DMARC2; p=reject",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateDMARC(tt.record)
if result != tt.expected {
t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestGenerateMXCheck(t *testing.T) {
tests := []struct {
name string
results *DNSResults
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid MX records",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
{Host: "mail2.example.com", Priority: 20, Valid: true},
},
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "No MX records",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Valid: false, Error: "No MX records found"},
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
{
name: "MX lookup failed",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Valid: false, Error: "DNS lookup failed"},
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateMXCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
})
}
}
func TestGenerateSPFCheck(t *testing.T) {
tests := []struct {
name string
spf *SPFRecord
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid SPF",
spf: &SPFRecord{
Record: "v=spf1 include:_spf.example.com -all",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Invalid SPF",
spf: &SPFRecord{
Record: "v=spf1 invalid syntax",
Valid: false,
Error: "SPF record appears malformed",
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.5,
},
{
name: "No SPF record",
spf: &SPFRecord{
Valid: false,
Error: "No SPF record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateSPFCheck(tt.spf)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
})
}
}
func TestGenerateDKIMCheck(t *testing.T) {
tests := []struct {
name string
dkim *DKIMRecord
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid DKIM",
dkim: &DKIMRecord{
Selector: "default",
Domain: "example.com",
Record: "v=DKIM1; k=rsa; p=MIGfMA0...",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Invalid DKIM",
dkim: &DKIMRecord{
Selector: "default",
Domain: "example.com",
Valid: false,
Error: "No DKIM record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateDKIMCheck(tt.dkim)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
if !strings.Contains(check.Name, tt.dkim.Selector) {
t.Errorf("Check name should contain selector %s", tt.dkim.Selector)
}
})
}
}
func TestGenerateDMARCCheck(t *testing.T) {
tests := []struct {
name string
dmarc *DMARCRecord
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid DMARC - reject",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=reject",
Policy: "reject",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Valid DMARC - quarantine",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=quarantine",
Policy: "quarantine",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Valid DMARC - none",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=none",
Policy: "none",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "No DMARC record",
dmarc: &DMARCRecord{
Valid: false,
Error: "No DMARC record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateDMARCCheck(tt.dmarc)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
// Check that advice mentions policy for valid DMARC
if tt.dmarc.Valid && check.Advice != nil {
if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") {
t.Error("Advice should mention 'none' policy")
}
}
})
}
}
func TestGenerateDNSChecks(t *testing.T) {
tests := []struct {
name string
results *DNSResults
minChecks int
}{
{
name: "Nil results",
results: nil,
minChecks: 0,
},
{
name: "Complete results",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
},
SPFRecord: &SPFRecord{
Record: "v=spf1 include:_spf.example.com -all",
Valid: true,
},
DKIMRecords: []DKIMRecord{
{
Selector: "default",
Domain: "example.com",
Valid: true,
},
},
DMARCRecord: &DMARCRecord{
Record: "v=DMARC1; p=quarantine",
Policy: "quarantine",
Valid: true,
},
},
minChecks: 4, // MX, SPF, DKIM, DMARC
},
{
name: "Partial results",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
},
},
minChecks: 1, // Only MX
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateDNSChecks(tt.results)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the DNS category
for _, check := range checks {
if check.Category != api.Dns {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns)
}
}
})
}
}
func TestAnalyzeDNS_NoDomain(t *testing.T) {
analyzer := NewDNSAnalyzer(5 * time.Second)
email := &EmailMessage{
Header: make(mail.Header),
// No From address
}
results := analyzer.AnalyzeDNS(email, nil)
if results == nil {
t.Fatal("Expected results, got nil")
}
if len(results.Errors) == 0 {
t.Error("Expected error when no domain can be extracted")
}
}
func TestExtractBIMITag(t *testing.T) {
tests := []struct {
name string
record string
tag string
expectedValue string
}{
{
name: "Extract logo URL (l tag)",
record: "v=BIMI1; l=https://example.com/logo.svg",
tag: "l",
expectedValue: "https://example.com/logo.svg",
},
{
name: "Extract VMC URL (a tag)",
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
tag: "a",
expectedValue: "https://example.com/vmc.pem",
},
{
name: "Tag not found",
record: "v=BIMI1; l=https://example.com/logo.svg",
tag: "a",
expectedValue: "",
},
{
name: "Tag with spaces",
record: "v=BIMI1; l= https://example.com/logo.svg ",
tag: "l",
expectedValue: "https://example.com/logo.svg",
},
{
name: "Empty record",
record: "",
tag: "l",
expectedValue: "",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractBIMITag(tt.record, tt.tag)
if result != tt.expectedValue {
t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue)
}
})
}
}
func TestValidateBIMI(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid BIMI with logo URL",
record: "v=BIMI1; l=https://example.com/logo.svg",
expected: true,
},
{
name: "Valid BIMI with logo and VMC",
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
expected: true,
},
{
name: "Invalid BIMI - no version",
record: "l=https://example.com/logo.svg",
expected: false,
},
{
name: "Invalid BIMI - wrong version",
record: "v=BIMI2; l=https://example.com/logo.svg",
expected: false,
},
{
name: "Invalid BIMI - no logo URL",
record: "v=BIMI1",
expected: false,
},
{
name: "Invalid BIMI - empty",
record: "",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateBIMI(tt.record)
if result != tt.expected {
t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestGenerateBIMICheck(t *testing.T) {
tests := []struct {
name string
bimi *BIMIRecord
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid BIMI with logo only",
bimi: &BIMIRecord{
Selector: "default",
Domain: "example.com",
Record: "v=BIMI1; l=https://example.com/logo.svg",
LogoURL: "https://example.com/logo.svg",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.0, // BIMI doesn't contribute to score
},
{
name: "Valid BIMI with VMC",
bimi: &BIMIRecord{
Selector: "default",
Domain: "example.com",
Record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
LogoURL: "https://example.com/logo.svg",
VMCURL: "https://example.com/vmc.pem",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.0,
},
{
name: "No BIMI record (optional)",
bimi: &BIMIRecord{
Selector: "default",
Domain: "example.com",
Valid: false,
Error: "No BIMI record found",
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0.0,
},
{
name: "Invalid BIMI record",
bimi: &BIMIRecord{
Selector: "default",
Domain: "example.com",
Record: "v=BIMI1",
Valid: false,
Error: "BIMI record appears malformed",
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateBIMICheck(tt.bimi)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
if check.Name != "BIMI Record" {
t.Errorf("Name = %q, want %q", check.Name, "BIMI Record")
}
// Check details for valid BIMI with VMC
if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil {
if !strings.Contains(*check.Details, "VMC URL") {
t.Error("Details should contain VMC URL for valid BIMI with VMC")
}
}
})
}
}

View file

@ -1,697 +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"
"net"
"net/mail"
"regexp"
"strings"
"time"
"golang.org/x/net/publicsuffix"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// HeaderAnalyzer analyzes email header quality and structure
type HeaderAnalyzer struct{}
// NewHeaderAnalyzer creates a new header analyzer
func NewHeaderAnalyzer() *HeaderAnalyzer {
return &HeaderAnalyzer{}
}
// CalculateHeaderScore evaluates email structural quality from header analysis
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *model.HeaderAnalysis) (int, rune) {
if analysis == nil || analysis.Headers == nil {
return 0, ' '
}
score := 0
maxGrade := 6
headers := *analysis.Headers
// RP and From alignment (25 points)
if analysis.DomainAlignment.Aligned == nil || !*analysis.DomainAlignment.RelaxedAligned {
// Bad domain alignment, cap grade to C
maxGrade -= 2
} else if *analysis.DomainAlignment.Aligned {
score += 25
} else if *analysis.DomainAlignment.RelaxedAligned {
score += 20
}
// Check required headers (RFC 5322) - 30 points
requiredHeaders := []string{"from", "date", "message-id"}
requiredCount := len(requiredHeaders)
presentRequired := 0
for _, headerName := range requiredHeaders {
if check, exists := headers[headerName]; exists && check.Present {
presentRequired++
}
}
if presentRequired == requiredCount {
score += 30
} else {
score += int(30 * (float32(presentRequired) / float32(requiredCount)))
maxGrade = 1
}
// Check recommended headers (15 points)
recommendedHeaders := []string{"subject", "to"}
// Add reply-to when from is a no-reply address
if h.isNoReplyAddress(headers["from"]) {
recommendedHeaders = append(recommendedHeaders, "reply-to")
}
recommendedCount := len(recommendedHeaders)
presentRecommended := 0
for _, headerName := range recommendedHeaders {
if check, exists := headers[headerName]; exists && check.Present {
presentRecommended++
}
}
score += presentRecommended * 15 / recommendedCount
if presentRecommended < recommendedCount {
maxGrade -= 1
}
// Check for proper MIME structure (20 points)
if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure {
score += 20
} else {
maxGrade -= 1
}
// Check MIME-Version header (-5 points if present but not "1.0")
if check, exists := headers["mime-version"]; exists && check.Present {
if check.Valid != nil && !*check.Valid {
score -= 5
}
}
// Check Message-ID format (10 points)
if check, exists := headers["message-id"]; exists && check.Present {
// If Valid is set and true, award points
if check.Valid != nil && *check.Valid {
score += 10
} else {
maxGrade -= 1
}
} else {
maxGrade -= 1
}
// Ensure score doesn't exceed 100
if score > 100 {
score = 100
}
grade := 'A' + max(6-maxGrade, 0)
return score, rune(grade)
}
// isValidMessageID checks if a Message-ID has proper format
func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool {
// Basic check: should be in format <...@...>
if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") {
return false
}
// Remove angle brackets
messageID = strings.TrimPrefix(messageID, "<")
messageID = strings.TrimSuffix(messageID, ">")
// Should contain @ symbol
if !strings.Contains(messageID, "@") {
return false
}
parts := strings.Split(messageID, "@")
if len(parts) != 2 {
return false
}
// Both parts should be non-empty
return len(parts[0]) > 0 && len(parts[1]) > 0
}
// parseEmailDate attempts to parse an email date string using common email date formats
// Returns the parsed time and an error if parsing fails
func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) {
// Remove timezone name in parentheses if present
dateStr = regexp.MustCompile(`\s*\([^)]+\)\s*$`).ReplaceAllString(strings.TrimSpace(dateStr), "")
// Try parsing with common email date formats
formats := []string{
time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700"
time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST"
"Mon, 2 Jan 2006 15:04:05 -0700",
"Mon, 2 Jan 2006 15:04:05 MST",
"2 Jan 2006 15:04:05 -0700",
}
for _, format := range formats {
if parsedTime, err := time.Parse(format, dateStr); err == nil {
return parsedTime, nil
}
}
return time.Time{}, fmt.Errorf("unable to parse date string: %s", dateStr)
}
// isNoReplyAddress checks if a header check represents a no-reply email address
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck model.HeaderCheck) bool {
if !headerCheck.Present || headerCheck.Value == nil {
return false
}
value := strings.ToLower(*headerCheck.Value)
noReplyPatterns := []string{
"no-reply",
"noreply",
"ne-pas-repondre",
"nepasrepondre",
}
for _, pattern := range noReplyPatterns {
if strings.Contains(value, pattern) {
return true
}
}
return false
}
// validateAddressHeader validates email address header using net/mail parser
// and returns the normalized address string in "Name <email>" format
func (h *HeaderAnalyzer) validateAddressHeader(value string) (string, error) {
// Try to parse as a single address first
if addr, err := mail.ParseAddress(value); err == nil {
return h.formatAddress(addr), nil
}
// If single address parsing fails, try parsing as an address list
// (for headers like To, Cc that can contain multiple addresses)
if addrs, err := mail.ParseAddressList(value); err != nil {
return "", err
} else {
// Join multiple addresses with ", "
result := ""
for i, addr := range addrs {
if i > 0 {
result += ", "
}
result += h.formatAddress(addr)
}
return result, nil
}
}
// formatAddress formats a mail.Address as "Name <email>" or just "email" if no name
func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
if addr.Name != "" {
return fmt.Sprintf("%s <%s>", addr.Name, addr.Address)
}
return addr.Address
}
// GenerateHeaderAnalysis creates structured header analysis from email
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *model.AuthenticationResults) *model.HeaderAnalysis {
if email == nil {
return nil
}
analysis := &model.HeaderAnalysis{}
// Check for proper MIME structure
analysis.HasMimeStructure = utils.PtrTo(len(email.Parts) > 0)
// Initialize headers map
headers := make(map[string]model.HeaderCheck)
// Check required headers
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
for _, headerName := range requiredHeaders {
check := h.checkHeader(email, headerName, "required")
headers[strings.ToLower(headerName)] = *check
}
// Check recommended headers
recommendedHeaders := []string{}
if h.isNoReplyAddress(headers["from"]) {
recommendedHeaders = append(recommendedHeaders, "reply-to")
}
for _, headerName := range recommendedHeaders {
check := h.checkHeader(email, headerName, "recommended")
headers[strings.ToLower(headerName)] = *check
}
// Check MIME-Version header (recommended but absence is not penalized)
mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended")
headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck
// Check optional headers
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"}
for _, headerName := range optionalHeaders {
check := h.checkHeader(email, headerName, "newsletter")
headers[strings.ToLower(headerName)] = *check
}
analysis.Headers = &headers
// Received chain
receivedChain := h.parseReceivedChain(email)
if len(receivedChain) > 0 {
analysis.ReceivedChain = &receivedChain
}
// Domain alignment
domainAlignment := h.analyzeDomainAlignment(email, authResults)
if domainAlignment != nil {
analysis.DomainAlignment = domainAlignment
}
// Header issues
issues := h.findHeaderIssues(email)
if len(issues) > 0 {
analysis.Issues = &issues
}
return analysis
}
// checkHeader checks if a header is present and valid
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *model.HeaderCheck {
value := email.GetHeaderValue(headerName)
present := email.HasHeader(headerName) && value != ""
importanceEnum := model.HeaderCheckImportance(importance)
check := &model.HeaderCheck{
Present: present,
Importance: &importanceEnum,
}
if present {
check.Value = &value
// Validate specific headers
valid := true
var headerIssues []string
switch headerName {
case "Message-ID":
if !h.isValidMessageID(value) {
valid = false
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
}
if len(email.Header["Message-Id"]) > 1 {
valid = false
headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"])))
}
case "Date":
// Validate date format
if _, err := h.parseEmailDate(value); err != nil {
valid = false
headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err))
}
case "MIME-Version":
if value != "1.0" {
valid = false
headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value))
}
case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path":
// Parse address header using net/mail and get normalized address
if normalizedAddr, err := h.validateAddressHeader(value); err != nil {
valid = false
headerIssues = append(headerIssues, fmt.Sprintf("Invalid email address format: %v", err))
} else {
// Use the normalized address as the value
check.Value = &normalizedAddr
}
}
check.Valid = &valid
if len(headerIssues) > 0 {
check.Issues = &headerIssues
}
} else {
valid := false
check.Valid = &valid
if importance == "required" {
issues := []string{"Required header is missing"}
check.Issues = &issues
}
}
return check
}
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *model.AuthenticationResults) *model.DomainAlignment {
alignment := &model.DomainAlignment{
Aligned: utils.PtrTo(true),
RelaxedAligned: utils.PtrTo(true),
}
// Extract From domain
fromAddr := email.GetHeaderValue("From")
if fromAddr != "" {
domain := h.extractDomain(fromAddr)
if domain != "" {
alignment.FromDomain = &domain
// Extract organizational domain
orgDomain := h.getOrganizationalDomain(domain)
alignment.FromOrgDomain = &orgDomain
}
}
// Extract Return-Path domain
returnPath := email.GetHeaderValue("Return-Path")
if returnPath != "" {
domain := h.extractDomain(returnPath)
if domain != "" {
alignment.ReturnPathDomain = &domain
// Extract organizational domain
orgDomain := h.getOrganizationalDomain(domain)
alignment.ReturnPathOrgDomain = &orgDomain
}
}
// Extract DKIM domains from authentication results
var dkimDomains []model.DKIMDomainInfo
if authResults != nil && authResults.Dkim != nil {
for _, dkim := range *authResults.Dkim {
if dkim.Domain != nil && *dkim.Domain != "" {
domain := *dkim.Domain
orgDomain := h.getOrganizationalDomain(domain)
dkimDomains = append(dkimDomains, model.DKIMDomainInfo{
Domain: domain,
OrgDomain: orgDomain,
})
}
}
}
if len(dkimDomains) > 0 {
alignment.DkimDomains = &dkimDomains
}
// Check alignment (strict and relaxed)
issues := []string{}
// hasReturnPath and hasDKIM track whether we have these fields to check
hasReturnPath := alignment.FromDomain != nil && alignment.ReturnPathDomain != nil
hasDKIM := alignment.FromDomain != nil && len(dkimDomains) > 0
// If neither Return-Path nor DKIM is present, keep default alignment (true)
// Otherwise, at least one must be aligned for overall alignment to be true
strictAligned := !hasReturnPath && !hasDKIM
relaxedAligned := !hasReturnPath && !hasDKIM
// Check Return-Path alignment
rpStrictAligned := false
rpRelaxedAligned := false
if hasReturnPath {
fromDomain := *alignment.FromDomain
rpDomain := *alignment.ReturnPathDomain
// Strict alignment: exact match (case-insensitive)
rpStrictAligned = strings.EqualFold(fromDomain, rpDomain)
// Relaxed alignment: organizational domain match
var fromOrgDomain, rpOrgDomain string
if alignment.FromOrgDomain != nil {
fromOrgDomain = *alignment.FromOrgDomain
}
if alignment.ReturnPathOrgDomain != nil {
rpOrgDomain = *alignment.ReturnPathOrgDomain
}
rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain)
if !rpStrictAligned {
if rpRelaxedAligned {
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not exactly match From domain (%s), but satisfies relaxed alignment (organizational domain: %s)", rpDomain, fromDomain, fromOrgDomain))
} else {
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain))
}
}
strictAligned = rpStrictAligned
relaxedAligned = rpRelaxedAligned
}
// Check DKIM alignment
dkimStrictAligned := false
dkimRelaxedAligned := false
if hasDKIM {
fromDomain := *alignment.FromDomain
var fromOrgDomain string
if alignment.FromOrgDomain != nil {
fromOrgDomain = *alignment.FromOrgDomain
}
for _, dkimDomain := range dkimDomains {
// Check strict alignment for this DKIM signature
if strings.EqualFold(fromDomain, dkimDomain.Domain) {
dkimStrictAligned = true
}
// Check relaxed alignment for this DKIM signature
if strings.EqualFold(fromOrgDomain, dkimDomain.OrgDomain) {
dkimRelaxedAligned = true
}
}
if !dkimStrictAligned && !dkimRelaxedAligned {
// List all DKIM domains that failed alignment
dkimDomainsList := []string{}
for _, dkimDomain := range dkimDomains {
dkimDomainsList = append(dkimDomainsList, dkimDomain.Domain)
}
issues = append(issues, fmt.Sprintf("DKIM signature domains (%s) do not align with From domain (%s) - neither strict nor relaxed alignment", strings.Join(dkimDomainsList, ", "), fromDomain))
} else if !dkimStrictAligned && dkimRelaxedAligned {
// DKIM has relaxed alignment but not strict
issues = append(issues, fmt.Sprintf("DKIM signature domains satisfy relaxed alignment with From domain (%s) but not strict alignment (organizational domain: %s)", fromDomain, fromOrgDomain))
}
// Overall alignment requires at least one method (Return-Path OR DKIM) to be aligned
// For DMARC compliance, at least one of SPF or DKIM must be aligned
if dkimStrictAligned {
strictAligned = true
}
if dkimRelaxedAligned {
relaxedAligned = true
}
}
*alignment.Aligned = strictAligned
*alignment.RelaxedAligned = relaxedAligned
if len(issues) > 0 {
alignment.Issues = &issues
}
return alignment
}
// extractDomain extracts domain from email address
func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
// Remove angle brackets if present
emailAddr = strings.Trim(emailAddr, "<> ")
// Find @ symbol
atIndex := strings.LastIndex(emailAddr, "@")
if atIndex == -1 {
return ""
}
domain := emailAddr[atIndex+1:]
// Remove any trailing >
domain = strings.TrimRight(domain, ">")
return domain
}
// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name
// 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
func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string {
domain = strings.ToLower(strings.TrimSpace(domain))
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)
// This correctly handles cases like .co.uk, .com.au, etc.
etldPlusOne, err := publicsuffix.EffectiveTLDPlusOne(domain)
if err != nil {
// Fallback to simple two-label extraction if PSL lookup fails
labels := strings.Split(domain, ".")
if len(labels) <= 2 {
return domain
}
return strings.Join(labels[len(labels)-2:], ".")
}
return etldPlusOne
}
// findHeaderIssues identifies issues with headers
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIssue {
var issues []model.HeaderIssue
// Check for missing required headers
requiredHeaders := []string{"From", "Date", "Message-ID"}
for _, header := range requiredHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
issues = append(issues, model.HeaderIssue{
Header: header,
Severity: model.HeaderIssueSeverityCritical,
Message: fmt.Sprintf("Required header '%s' is missing", header),
Advice: utils.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
})
}
}
// Check Message-ID format
messageID := email.GetHeaderValue("Message-ID")
if messageID != "" && !h.isValidMessageID(messageID) {
issues = append(issues, model.HeaderIssue{
Header: "Message-ID",
Severity: model.HeaderIssueSeverityMedium,
Message: "Message-ID format is invalid",
Advice: utils.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
})
}
return issues
}
// parseReceivedChain extracts the chain of Received headers from an email
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop {
if email == nil || email.Header == nil {
return nil
}
receivedHeaders := email.Header["Received"]
if len(receivedHeaders) == 0 {
return nil
}
var chain []model.ReceivedHop
for _, receivedValue := range receivedHeaders {
hop := h.parseReceivedHeader(receivedValue)
if hop != nil {
chain = append(chain, *hop)
}
}
return chain
}
// parseReceivedHeader parses a single Received header value
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.ReceivedHop {
hop := &model.ReceivedHop{}
// Normalize whitespace - Received headers can span multiple lines
normalized := strings.Join(strings.Fields(receivedValue), " ")
// Check if this is a "by-first" header (e.g., "by hostname (Postfix, from userid...)")
// vs standard "from-first" header (e.g., "from hostname ... by hostname")
isByFirst := regexp.MustCompile(`^by\s+`).MatchString(strings.TrimSpace(normalized))
// Extract "from" field - only if not in "by-first" format
// Avoid matching "from" inside parentheses after "by"
if !isByFirst {
fromRegex := regexp.MustCompile(`(?i)^from\s+([^\s(]+)`)
if matches := fromRegex.FindStringSubmatch(normalized); len(matches) > 1 {
from := matches[1]
hop.From = &from
}
}
// Extract "by" field
byRegex := regexp.MustCompile(`(?i)by\s+([^\s(]+)`)
if matches := byRegex.FindStringSubmatch(normalized); len(matches) > 1 {
by := matches[1]
hop.By = &by
}
// Extract "with" field (protocol) - must come after "by" and before "id" or "for"
// This ensures we get the mail transfer protocol, not other "with" occurrences
// Avoid matching "with" inside parentheses (like in TLS details)
withRegex := regexp.MustCompile(`(?i)by\s+[^\s(]+[^;]*?\s+with\s+([A-Z0-9]+)(?:\s|;)`)
if matches := withRegex.FindStringSubmatch(normalized); len(matches) > 1 {
with := matches[1]
hop.With = &with
}
// Extract "id" field - should come after "with" or "by", not inside parentheses
// Match pattern: "id <value>" where value doesn't contain parentheses or semicolons
idRegex := regexp.MustCompile(`(?i)\s+id\s+([^\s;()]+)`)
if matches := idRegex.FindStringSubmatch(normalized); len(matches) > 1 {
id := matches[1]
hop.Id = &id
}
// Extract IP address from parentheses after "from"
// Pattern: from hostname (anything [IPv4/IPv6])
ipRegex := regexp.MustCompile(`\[([^\]]+)\]`)
if matches := ipRegex.FindStringSubmatch(normalized); len(matches) > 1 {
ipStr := matches[1]
// Handle IPv6: prefix (some MTAs include this)
ipStr = strings.TrimPrefix(ipStr, "IPv6:")
// Check if it's a valid IP (IPv4 or IPv6)
if net.ParseIP(ipStr) != nil {
hop.Ip = &ipStr
// Perform reverse DNS lookup
if reverseNames, err := net.LookupAddr(ipStr); err == nil && len(reverseNames) > 0 {
// Remove trailing dot from PTR record
reverse := strings.TrimSuffix(reverseNames[0], ".")
hop.Reverse = &reverse
}
}
}
// Extract timestamp - usually at the end after semicolon
// Common formats: "for <...>; Tue, 15 Oct 2024 12:34:56 +0000 (UTC)"
timestampRegex := regexp.MustCompile(`;\s*(.+)$`)
if matches := timestampRegex.FindStringSubmatch(normalized); len(matches) > 1 {
timestampStr := strings.TrimSpace(matches[1])
// Use the dedicated date parsing function
if parsedTime, err := h.parseEmailDate(timestampStr); err == nil {
hop.Timestamp = &parsedTime
}
}
return hop
}

File diff suppressed because it is too large Load diff

View file

@ -211,27 +211,8 @@ func buildRawHeaders(header mail.Header) string {
}
// GetAuthenticationResults extracts Authentication-Results headers
// If receiverHostname is provided, only returns headers that begin with that hostname
func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string {
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
// If no hostname specified, return all results
if receiverHostname == "" {
return allResults
}
// Filter results that begin with the specified hostname
var filtered []string
prefix := receiverHostname + ";"
for _, result := range allResults {
// Trim whitespace and check if it starts with hostname;
trimmed := strings.TrimSpace(result)
if strings.HasPrefix(trimmed, prefix) {
filtered = append(filtered, result)
}
}
return filtered
func (e *EmailMessage) GetAuthenticationResults() []string {
return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
}
// GetSpamAssassinHeaders extracts SpamAssassin-related headers
@ -249,33 +230,6 @@ func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string {
}
for _, headerName := range saHeaders {
if values, ok := e.Header[headerName]; ok && len(values) > 0 {
for _, value := range values {
if strings.TrimSpace(value) != "" {
headers[headerName] = value
break
}
}
} else if value := e.Header.Get(headerName); value != "" {
headers[headerName] = value
}
}
return headers
}
// GetRspamdHeaders extracts rspamd-related headers
func (e *EmailMessage) GetRspamdHeaders() map[string]string {
headers := make(map[string]string)
rspamdHeaders := []string{
"X-Spamd-Result",
"X-Rspamd-Score",
"X-Rspamd-Action",
"X-Rspamd-Server",
}
for _, headerName := range rspamdHeaders {
if value := e.Header.Get(headerName); value != "" {
headers[headerName] = value
}
@ -321,20 +275,3 @@ func (e *EmailMessage) GetHeaderValue(key string) string {
func (e *EmailMessage) HasHeader(key string) bool {
return e.Header.Get(key) != ""
}
// GetListUnsubscribeURLs parses the List-Unsubscribe header and returns all URLs.
// The header format is: <url1>, <url2>, ...
func (e *EmailMessage) GetListUnsubscribeURLs() []string {
value := e.Header.Get("List-Unsubscribe")
if value == "" {
return nil
}
var urls []string
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "<") && strings.HasSuffix(part, ">") {
urls = append(urls, part[1:len(part)-1])
}
}
return urls
}

View file

@ -120,7 +120,7 @@ Body content.
t.Fatalf("Failed to parse email: %v", err)
}
authResults := email.GetAuthenticationResults("example.com")
authResults := email.GetAuthenticationResults()
if len(authResults) != 2 {
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
}

View file

@ -27,22 +27,16 @@ import (
"net"
"regexp"
"strings"
"sync"
"time"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
type DNSListChecker struct {
Timeout time.Duration
Lists []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one
filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
resolver *net.Resolver
informationalSet map[string]bool // Lists whose hits don't count toward the score
// RBLChecker checks IP addresses against DNS-based blacklists
type RBLChecker struct {
Timeout time.Duration
RBLs []string
resolver *net.Resolver
}
// DefaultRBLs is a list of commonly used RBL providers
@ -53,83 +47,46 @@ var DefaultRBLs = []string{
"b.barracudacentral.org", // Barracuda
"cbl.abuseat.org", // CBL (Composite Blocking List)
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2 (informational)
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3 (informational)
"psbl.surriel.com", // PSBL
"dnsbl.dronebl.org", // DroneBL
"bl.mailspike.net", // Mailspike BL
"z.mailspike.net", // Mailspike Z
"bl.rbl-dns.com", // RBL-DNS
"bl.nszones.com", // NSZones
}
// DefaultInformationalRBLs lists RBLs that are checked but not counted in the score.
// These are typically broader lists where being listed is less definitive.
var DefaultInformationalRBLs = []string{
"dnsbl-2.uceprotect.net", // UCEPROTECT Level 2: entire netblocks, may cause false positives
"dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring
}
// DefaultDNSWLs is a list of commonly used DNSWL providers
var DefaultDNSWLs = []string{
"list.dnswl.org", // DNSWL.org — the main DNS whitelist
"swl.spamhaus.org", // Spamhaus Safe Whitelist
}
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker {
func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker {
if timeout == 0 {
timeout = 5 * time.Second
timeout = 5 * time.Second // Default timeout
}
if len(rbls) == 0 {
rbls = DefaultRBLs
}
informationalSet := make(map[string]bool, len(DefaultInformationalRBLs))
for _, rbl := range DefaultInformationalRBLs {
informationalSet[rbl] = true
}
return &DNSListChecker{
Timeout: timeout,
Lists: rbls,
CheckAllIPs: checkAllIPs,
filterErrorCodes: true,
resolver: &net.Resolver{PreferGo: true},
informationalSet: informationalSet,
return &RBLChecker{
Timeout: timeout,
RBLs: rbls,
resolver: &net.Resolver{
PreferGo: true,
},
}
}
// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list
func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker {
if timeout == 0 {
timeout = 5 * time.Second
}
if len(dnswls) == 0 {
dnswls = DefaultDNSWLs
}
return &DNSListChecker{
Timeout: timeout,
Lists: dnswls,
CheckAllIPs: checkAllIPs,
filterErrorCodes: false,
resolver: &net.Resolver{PreferGo: true},
informationalSet: make(map[string]bool),
}
// RBLResults represents the results of RBL checks
type RBLResults struct {
Checks []RBLCheck
IPsChecked []string
ListedCount int
}
// DNSListResults represents the results of DNS list checks
type DNSListResults struct {
Checks map[string][]model.BlacklistCheck // Map of IP -> list of checks for that IP
IPsChecked []string
ListedCount int // Total listings including informational entries
RelevantListedCount int // Listings on scoring (non-informational) lists only
// RBLCheck represents a single RBL check result
type RBLCheck struct {
IP string
RBL string
Listed bool
Response string
Error string
}
// CheckEmail checks all IPs found in the email headers against the configured lists
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
results := &DNSListResults{
Checks: make(map[string][]model.BlacklistCheck),
}
// CheckEmail checks all IPs found in the email headers against RBLs
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
results := &RBLResults{}
// Extract IPs from Received headers
ips := r.extractIPs(email)
if len(ips) == 0 {
return results
@ -137,68 +94,42 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
results.IPsChecked = ips
// Check each IP against all RBLs
for _, ip := range ips {
for _, list := range r.Lists {
check := r.checkIP(ip, list)
results.Checks[ip] = append(results.Checks[ip], check)
for _, rbl := range r.RBLs {
check := r.checkIP(ip, rbl)
results.Checks = append(results.Checks, check)
if check.Listed {
results.ListedCount++
if !r.informationalSet[list] {
results.RelevantListedCount++
}
}
}
if !r.CheckAllIPs {
break
}
}
return results
}
// CheckIP checks a single IP address against all configured lists in parallel
func (r *DNSListChecker) CheckIP(ip string) ([]model.BlacklistCheck, int, error) {
if !r.isPublicIP(ip) {
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
}
checks := make([]model.BlacklistCheck, len(r.Lists))
var wg sync.WaitGroup
for i, list := range r.Lists {
wg.Add(1)
go func(i int, list string) {
defer wg.Done()
checks[i] = r.checkIP(ip, list)
}(i, list)
}
wg.Wait()
listedCount := 0
for _, check := range checks {
if check.Listed {
listedCount++
}
}
return checks, listedCount, nil
}
// extractIPs extracts IP addresses from Received headers
func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
var ips []string
seenIPs := make(map[string]bool)
// Get all Received headers
receivedHeaders := email.Header["Received"]
// Regex patterns for IP addresses
// Match IPv4: xxx.xxx.xxx.xxx
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
// Look for IPs in Received headers
for _, received := range receivedHeaders {
// Find all IPv4 addresses
matches := ipv4Pattern.FindAllString(received, -1)
for _, match := range matches {
// Skip private/reserved IPs
if !r.isPublicIP(match) {
continue
}
// Avoid duplicates
if !seenIPs[match] {
ips = append(ips, match)
seenIPs[match] = true
@ -206,10 +137,13 @@ func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
}
}
// If no IPs found in Received headers, try X-Originating-IP
if len(ips) == 0 {
originatingIP := email.Header.Get("X-Originating-IP")
if originatingIP != "" {
// Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
// Remove any whitespace
cleanIP = strings.TrimSpace(cleanIP)
matches := ipv4Pattern.FindString(cleanIP)
if matches != "" && r.isPublicIP(matches) {
@ -222,16 +156,19 @@ func (r *DNSListChecker) extractIPs(email *EmailMessage) []string {
}
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
func (r *DNSListChecker) isPublicIP(ipStr string) bool {
func (r *RBLChecker) isPublicIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
// Check if it's a private network
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
// Additional checks for reserved ranges
// 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
if ip.IsUnspecified() {
return false
}
@ -239,120 +176,233 @@ func (r *DNSListChecker) isPublicIP(ipStr string) bool {
return true
}
// checkIP checks a single IP against a single DNS list
func (r *DNSListChecker) checkIP(ip, list string) model.BlacklistCheck {
check := model.BlacklistCheck{
Rbl: list,
// checkIP checks a single IP against a single RBL
func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck {
check := RBLCheck{
IP: ip,
RBL: rbl,
}
// Reverse the IP for DNSBL query
reversedIP := r.reverseIP(ip)
if reversedIP == "" {
check.Error = utils.PtrTo("Failed to reverse IP address")
check.Error = "Failed to reverse IP address"
return check
}
query := fmt.Sprintf("%s.%s", reversedIP, list)
// Construct DNSBL query: reversed-ip.rbl-domain
query := fmt.Sprintf("%s.%s", reversedIP, rbl)
// Perform DNS lookup with timeout
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
defer cancel()
addrs, err := r.resolver.LookupHost(ctx, query)
if err != nil {
// Most likely not listed (NXDOMAIN)
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsNotFound {
check.Listed = false
return check
}
}
check.Error = utils.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
// Other DNS errors
check.Error = fmt.Sprintf("DNS lookup failed: %v", err)
return check
}
// If we got a response, the IP is listed
if len(addrs) > 0 {
check.Response = utils.PtrTo(addrs[0])
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
check.Listed = false
check.Error = utils.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
} else {
check.Listed = true
}
check.Listed = true
check.Response = addrs[0] // Return code (e.g., 127.0.0.2)
}
return check
}
// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries
// reverseIP reverses an IPv4 address for DNSBL queries
// Example: 192.0.2.1 -> 1.2.0.192
func (r *DNSListChecker) reverseIP(ipStr string) string {
func (r *RBLChecker) reverseIP(ipStr string) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ""
}
// Convert to IPv4
ipv4 := ip.To4()
if ipv4 == nil {
return "" // IPv6 not supported yet
}
// Reverse the octets
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
}
// CalculateScore calculates the list contribution to deliverability.
// Informational lists are not counted in the score.
func (r *DNSListChecker) CalculateScore(results *DNSListResults, forWhitelist bool) (int, string) {
scoringListCount := len(r.Lists) - len(r.informationalSet)
// GetBlacklistScore calculates the blacklist contribution to deliverability (0-2 points)
// Scoring:
// - Not listed on any RBL: 2 points (excellent)
// - Listed on 1 RBL: 1 point (warning)
// - Listed on 2-3 RBLs: 0.5 points (poor)
// - Listed on 4+ RBLs: 0 points (critical)
func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 {
if results == nil || len(results.IPsChecked) == 0 {
// No IPs to check, give benefit of doubt
return 2.0
}
if forWhitelist {
if results.ListedCount >= scoringListCount {
return 100, "A++"
} else if results.ListedCount > 0 {
return 100, "A+"
} else {
return 95, "A"
listedCount := results.ListedCount
if listedCount == 0 {
return 2.0
} else if listedCount == 1 {
return 1.0
} else if listedCount <= 3 {
return 0.5
}
return 0.0
}
// GenerateRBLChecks generates check results for RBL analysis
func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check {
var checks []api.Check
if results == nil {
return checks
}
// If no IPs were checked, add a warning
if len(results.IPsChecked) == 0 {
checks = append(checks, api.Check{
Category: api.Blacklist,
Name: "RBL Check",
Status: api.CheckStatusWarn,
Score: 1.0,
Message: "No public IP addresses found to check",
Severity: api.PtrTo(api.CheckSeverityLow),
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
})
return checks
}
// Create a summary check
summaryCheck := r.generateSummaryCheck(results)
checks = append(checks, summaryCheck)
// Create individual checks for each listing
for _, check := range results.Checks {
if check.Listed {
detailCheck := r.generateListingCheck(&check)
checks = append(checks, detailCheck)
}
}
if results == nil || len(results.IPsChecked) == 0 {
return 100, ""
}
if results.ListedCount <= 0 {
return 100, "A+"
}
percentage := 100 - results.RelevantListedCount*100/scoringListCount
return percentage, ScoreToGrade(percentage)
return checks
}
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
// generateSummaryCheck creates an overall RBL summary check
func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
check := api.Check{
Category: api.Blacklist,
Name: "RBL Summary",
}
score := r.GetBlacklistScore(results)
check.Score = score
totalChecks := len(results.Checks)
listedCount := results.ListedCount
if listedCount == 0 {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your sending IP has a good reputation")
} else if listedCount == 1 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
} else if listedCount <= 3 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
} else {
check.Status = api.CheckStatusFail
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL")
}
// Add details about IPs checked
if len(results.IPsChecked) > 0 {
details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", "))
check.Details = &details
}
return check
}
// generateListingCheck creates a check for a specific RBL listing
func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check {
check := api.Check{
Category: api.Blacklist,
Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
Status: api.CheckStatusFail,
Score: 0.0,
}
check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL)
// Determine severity based on which RBL
if strings.Contains(rblCheck.RBL, "spamhaus") {
check.Severity = api.PtrTo(api.CheckSeverityCritical)
advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
check.Advice = &advice
} else if strings.Contains(rblCheck.RBL, "spamcop") {
check.Severity = api.PtrTo(api.CheckSeverityHigh)
advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
check.Advice = &advice
} else {
check.Severity = api.PtrTo(api.CheckSeverityHigh)
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
check.Advice = &advice
}
// Add response code details
if rblCheck.Response != "" {
details := fmt.Sprintf("Response: %s", rblCheck.Response)
check.Details = &details
}
return check
}
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
seenIPs := make(map[string]bool)
var listedIPs []string
for ip, checks := range results.Checks {
for _, check := range checks {
if check.Listed {
listedIPs = append(listedIPs, ip)
break
}
for _, check := range results.Checks {
if check.Listed && !seenIPs[check.IP] {
listedIPs = append(listedIPs, check.IP)
seenIPs[check.IP] = true
}
}
return listedIPs
}
// GetListsForIP returns all lists that match a specific IP
func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string {
var lists []string
// GetRBLsForIP returns all RBLs that list a specific IP
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
var rbls []string
if checks, exists := results.Checks[ip]; exists {
for _, check := range checks {
if check.Listed {
lists = append(lists, check.Rbl)
}
for _, check := range results.Checks {
if check.IP == ip && check.Listed {
rbls = append(rbls, check.RBL)
}
}
return lists
return rbls
}

View file

@ -23,10 +23,11 @@ package analyzer
import (
"net/mail"
"strings"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewRBLChecker(t *testing.T) {
@ -55,12 +56,12 @@ func TestNewRBLChecker(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checker := NewRBLChecker(tt.timeout, tt.rbls, false)
checker := NewRBLChecker(tt.timeout, tt.rbls)
if checker.Timeout != tt.expectedTimeout {
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
}
if len(checker.Lists) != tt.expectedRBLs {
t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs)
if len(checker.RBLs) != tt.expectedRBLs {
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
}
if checker.resolver == nil {
t.Error("Resolver should not be nil")
@ -97,7 +98,7 @@ func TestReverseIP(t *testing.T) {
},
}
checker := NewRBLChecker(5*time.Second, nil, false)
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -157,7 +158,7 @@ func TestIsPublicIP(t *testing.T) {
},
}
checker := NewRBLChecker(5*time.Second, nil, false)
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -237,7 +238,7 @@ func TestExtractIPs(t *testing.T) {
},*/
}
checker := NewRBLChecker(5*time.Second, nil, false)
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -265,68 +266,68 @@ func TestExtractIPs(t *testing.T) {
func TestGetBlacklistScore(t *testing.T) {
tests := []struct {
name string
results *DNSListResults
expectedScore int
results *RBLResults
expectedScore float32
}{
{
name: "Nil results",
results: nil,
expectedScore: 100,
expectedScore: 2.0,
},
{
name: "No IPs checked",
results: &DNSListResults{
results: &RBLResults{
IPsChecked: []string{},
},
expectedScore: 100,
expectedScore: 2.0,
},
{
name: "Not listed on any RBL",
results: &DNSListResults{
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
},
expectedScore: 100,
expectedScore: 2.0,
},
{
name: "Listed on 1 RBL",
results: &DNSListResults{
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 1,
},
expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16)
expectedScore: 1.0,
},
{
name: "Listed on 2 RBLs",
results: &DNSListResults{
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
},
expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33)
expectedScore: 0.5,
},
{
name: "Listed on 3 RBLs",
results: &DNSListResults{
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 3,
},
expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50)
expectedScore: 0.5,
},
{
name: "Listed on 4+ RBLs",
results: &DNSListResults{
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 4,
},
expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66)
expectedScore: 0.0,
},
}
checker := NewRBLChecker(5*time.Second, nil, false)
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score, _ := checker.CalculateScore(tt.results)
score := checker.GetBlacklistScore(tt.results)
if score != tt.expectedScore {
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
}
@ -334,24 +335,215 @@ func TestGetBlacklistScore(t *testing.T) {
}
}
func TestGetUniqueListedIPs(t *testing.T) {
results := &DNSListResults{
Checks: map[string][]model.BlacklistCheck{
"198.51.100.1": {
{Rbl: "zen.spamhaus.org", Listed: true},
{Rbl: "bl.spamcop.net", Listed: true},
func TestGenerateSummaryCheck(t *testing.T) {
tests := []struct {
name string
results *RBLResults
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Not listed",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
Checks: make([]RBLCheck, 6), // 6 default RBLs
},
"198.51.100.2": {
{Rbl: "zen.spamhaus.org", Listed: true},
{Rbl: "bl.spamcop.net", Listed: false},
expectedStatus: api.CheckStatusPass,
expectedScore: 2.0,
},
{
name: "Listed on 1 RBL",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 1,
Checks: make([]RBLCheck, 6),
},
"198.51.100.3": {
{Rbl: "zen.spamhaus.org", Listed: false},
expectedStatus: api.CheckStatusWarn,
expectedScore: 1.0,
},
{
name: "Listed on 2 RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
Checks: make([]RBLCheck, 6),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.5,
},
{
name: "Listed on 4+ RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 4,
Checks: make([]RBLCheck, 6),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
checker := NewRBLChecker(5*time.Second, nil, false)
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := checker.generateSummaryCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Blacklist {
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
}
})
}
}
func TestGenerateListingCheck(t *testing.T) {
tests := []struct {
name string
rblCheck *RBLCheck
expectedStatus api.CheckStatus
expectedSeverity api.CheckSeverity
}{
{
name: "Spamhaus listing",
rblCheck: &RBLCheck{
IP: "198.51.100.1",
RBL: "zen.spamhaus.org",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.CheckSeverityCritical,
},
{
name: "SpamCop listing",
rblCheck: &RBLCheck{
IP: "198.51.100.1",
RBL: "bl.spamcop.net",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.CheckSeverityHigh,
},
{
name: "Other RBL listing",
rblCheck: &RBLCheck{
IP: "198.51.100.1",
RBL: "dnsbl.sorbs.net",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.CheckSeverityHigh,
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := checker.generateListingCheck(tt.rblCheck)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Severity == nil || *check.Severity != tt.expectedSeverity {
t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity)
}
if check.Category != api.Blacklist {
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
}
if !strings.Contains(check.Name, tt.rblCheck.RBL) {
t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL)
}
})
}
}
func TestGenerateRBLChecks(t *testing.T) {
tests := []struct {
name string
results *RBLResults
minChecks int
}{
{
name: "Nil results",
results: nil,
minChecks: 0,
},
{
name: "No IPs checked",
results: &RBLResults{
IPsChecked: []string{},
},
minChecks: 1, // Warning check
},
{
name: "Not listed on any RBL",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: false},
},
},
minChecks: 1, // Summary check only
},
{
name: "Listed on 2 RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
{IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
},
},
minChecks: 3, // Summary + 2 listing checks
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := checker.GenerateRBLChecks(tt.results)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Blacklist category
for _, check := range checks {
if check.Category != api.Blacklist {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist)
}
}
})
}
}
func TestGetUniqueListedIPs(t *testing.T) {
results := &RBLResults{
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
{IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false},
{IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false},
},
}
checker := NewRBLChecker(5*time.Second, nil)
listedIPs := checker.GetUniqueListedIPs(results)
expectedIPs := []string{"198.51.100.1", "198.51.100.2"}
@ -363,20 +555,16 @@ func TestGetUniqueListedIPs(t *testing.T) {
}
func TestGetRBLsForIP(t *testing.T) {
results := &DNSListResults{
Checks: map[string][]model.BlacklistCheck{
"198.51.100.1": {
{Rbl: "zen.spamhaus.org", Listed: true},
{Rbl: "bl.spamcop.net", Listed: true},
{Rbl: "dnsbl.sorbs.net", Listed: false},
},
"198.51.100.2": {
{Rbl: "zen.spamhaus.org", Listed: true},
},
results := &RBLResults{
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
{IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
{IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
},
}
checker := NewRBLChecker(5*time.Second, nil, false)
checker := NewRBLChecker(5*time.Second, nil)
tests := []struct {
name string
@ -402,7 +590,7 @@ func TestGetRBLsForIP(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rbls := checker.GetListsForIP(results, tt.ip)
rbls := checker.GetRBLsForIP(results, tt.ip)
if len(rbls) != len(tt.expectedRBLs) {
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))

View file

@ -24,8 +24,7 @@ package analyzer
import (
"time"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
"github.com/google/uuid"
)
@ -33,47 +32,37 @@ import (
type ReportGenerator struct {
authAnalyzer *AuthenticationAnalyzer
spamAnalyzer *SpamAssassinAnalyzer
rspamdAnalyzer *RspamdAnalyzer
dnsAnalyzer *DNSAnalyzer
rblChecker *DNSListChecker
dnswlChecker *DNSListChecker
rblChecker *RBLChecker
contentAnalyzer *ContentAnalyzer
headerAnalyzer *HeaderAnalyzer
scorer *DeliverabilityScorer
}
// NewReportGenerator creates a new report generator
func NewReportGenerator(
receiverHostname string,
dnsTimeout time.Duration,
httpTimeout time.Duration,
rbls []string,
dnswls []string,
checkAllIPs bool,
rspamdAPIURL string,
) *ReportGenerator {
return &ReportGenerator{
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
authAnalyzer: NewAuthenticationAnalyzer(),
spamAnalyzer: NewSpamAssassinAnalyzer(),
rspamdAnalyzer: NewRspamdAnalyzer(LoadRspamdSymbols(rspamdAPIURL)),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
rblChecker: NewRBLChecker(dnsTimeout, rbls),
contentAnalyzer: NewContentAnalyzer(httpTimeout),
headerAnalyzer: NewHeaderAnalyzer(),
scorer: NewDeliverabilityScorer(),
}
}
// AnalysisResults contains all intermediate analysis results
type AnalysisResults struct {
Email *EmailMessage
Authentication *model.AuthenticationResults
Authentication *api.AuthenticationResults
SpamAssassin *SpamAssassinResult
DNS *DNSResults
RBL *RBLResults
Content *ContentResults
DNS *model.DNSResults
Headers *model.HeaderAnalysis
RBL *DNSListResults
DNSWL *DNSListResults
SpamAssassin *model.SpamAssassinResult
Rspamd *model.RspamdResult
Score *ScoringResult
}
// AnalyzeEmail performs complete email analysis
@ -84,217 +73,248 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
// Run all analyzers
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
results.RBL = r.rblChecker.CheckEmail(email)
results.DNSWL = r.dnswlChecker.CheckEmail(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication)
results.RBL = r.rblChecker.CheckEmail(email)
results.Content = r.contentAnalyzer.AnalyzeContent(email)
// Calculate overall score
results.Score = r.scorer.CalculateScore(
results.Authentication,
results.SpamAssassin,
results.RBL,
results.Content,
email,
)
return 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()
now := time.Now()
report := &model.Report{
Id: utils.UUIDToBase32(reportID),
TestId: utils.UUIDToBase32(testID),
report := &api.Report{
Id: reportID,
TestId: testID,
Score: results.Score.OverallScore,
CreatedAt: now,
}
// Calculate scores directly from analyzers (no more checks array)
dnsScore := 0
var dnsGrade string
if results.DNS != nil {
// Extract sender IP from received chain for FCrDNS verification
var senderIP string
if results.Headers != nil && results.Headers.ReceivedChain != nil && len(*results.Headers.ReceivedChain) > 0 {
firstHop := (*results.Headers.ReceivedChain)[0]
if firstHop.Ip != nil {
senderIP = *firstHop.Ip
}
}
dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS, senderIP)
// Build score summary
report.Summary = &api.ScoreSummary{
AuthenticationScore: results.Score.AuthScore,
SpamScore: results.Score.SpamScore,
BlacklistScore: results.Score.BlacklistScore,
ContentScore: results.Score.ContentScore,
HeaderScore: results.Score.HeaderScore,
}
authScore := 0
var authGrade string
// Collect all checks from different analyzers
checks := []api.Check{}
// Authentication checks
if results.Authentication != nil {
authScore, authGrade = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication)
authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication)
checks = append(checks, authChecks...)
}
contentScore := 0
var contentGrade string
if results.Content != nil {
contentScore, contentGrade = r.contentAnalyzer.CalculateContentScore(results.Content)
// DNS checks
if results.DNS != nil {
dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS)
checks = append(checks, dnsChecks...)
}
headerScore := 0
var headerGrade rune
if results.Headers != nil {
headerScore, headerGrade = r.headerAnalyzer.CalculateHeaderScore(results.Headers)
}
blacklistScore := 0
var blacklistGrade string
var whitelistGrade string
// RBL checks
if results.RBL != nil {
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL, false)
_, whitelistGrade = r.dnswlChecker.CalculateScore(results.DNSWL, true)
rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL)
checks = append(checks, rblChecks...)
}
saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
rspamdScore, rspamdGrade := r.rspamdAnalyzer.CalculateRspamdScore(results.Rspamd)
// Combine SpamAssassin and rspamd scores 50/50.
// If only one filter ran (the other returns "" grade), use that filter's score alone.
var spamScore int
var spamGrade string
switch {
case saGrade == "" && rspamdGrade == "":
spamScore = 0
spamGrade = ""
case saGrade == "":
spamScore = rspamdScore
spamGrade = rspamdGrade
case rspamdGrade == "":
spamScore = saScore
spamGrade = saGrade
default:
spamScore = (saScore + rspamdScore) / 2
spamGrade = MinGrade(saGrade, rspamdGrade)
// SpamAssassin checks
if results.SpamAssassin != nil {
spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin)
checks = append(checks, spamChecks...)
}
report.Summary = &model.ScoreSummary{
DnsScore: dnsScore,
DnsGrade: model.ScoreSummaryDnsGrade(dnsGrade),
AuthenticationScore: authScore,
AuthenticationGrade: model.ScoreSummaryAuthenticationGrade(authGrade),
BlacklistScore: blacklistScore,
BlacklistGrade: model.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
ContentScore: contentScore,
ContentGrade: model.ScoreSummaryContentGrade(contentGrade),
HeaderScore: headerScore,
HeaderGrade: model.ScoreSummaryHeaderGrade(headerGrade),
SpamScore: spamScore,
SpamGrade: model.ScoreSummarySpamGrade(spamGrade),
// Content checks
if results.Content != nil {
contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content)
checks = append(checks, contentChecks...)
}
// Header checks
headerChecks := r.scorer.GenerateHeaderChecks(results.Email)
checks = append(checks, headerChecks...)
report.Checks = checks
// Add authentication results
report.Authentication = results.Authentication
// Add content analysis
if results.Content != nil {
contentAnalysis := r.contentAnalyzer.GenerateContentAnalysis(results.Content)
report.ContentAnalysis = contentAnalysis
// Add SpamAssassin result
if results.SpamAssassin != nil {
report.Spamassassin = &api.SpamAssassinResult{
Score: float32(results.SpamAssassin.Score),
RequiredScore: float32(results.SpamAssassin.RequiredScore),
IsSpam: results.SpamAssassin.IsSpam,
}
if len(results.SpamAssassin.Tests) > 0 {
report.Spamassassin.Tests = &results.SpamAssassin.Tests
}
if results.SpamAssassin.RawReport != "" {
report.Spamassassin.Report = &results.SpamAssassin.RawReport
}
}
// Add DNS records
if results.DNS != nil {
report.DnsResults = results.DNS
dnsRecords := r.buildDNSRecords(results.DNS)
if len(dnsRecords) > 0 {
report.DnsRecords = &dnsRecords
}
}
// Add headers results
report.HeaderAnalysis = results.Headers
// Add blacklist checks as a map of IP -> array of BlacklistCheck
// Add blacklist checks
if results.RBL != nil && len(results.RBL.Checks) > 0 {
report.Blacklists = &results.RBL.Checks
blacklistChecks := make([]api.BlacklistCheck, 0, len(results.RBL.Checks))
for _, check := range results.RBL.Checks {
blCheck := api.BlacklistCheck{
Ip: check.IP,
Rbl: check.RBL,
Listed: check.Listed,
}
if check.Response != "" {
blCheck.Response = &check.Response
}
blacklistChecks = append(blacklistChecks, blCheck)
}
report.Blacklists = &blacklistChecks
}
// Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only)
if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 {
report.Whitelists = &results.DNSWL.Checks
}
// Add SpamAssassin result with individual deliverability score
if results.SpamAssassin != nil {
saGradeTyped := model.SpamAssassinResultDeliverabilityGrade(saGrade)
results.SpamAssassin.DeliverabilityScore = utils.PtrTo(saScore)
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
}
report.Spamassassin = results.SpamAssassin
// Add rspamd result with individual deliverability score
if results.Rspamd != nil {
rspamdGradeTyped := model.RspamdResultDeliverabilityGrade(rspamdGrade)
results.Rspamd.DeliverabilityScore = utils.PtrTo(rspamdScore)
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
}
report.Rspamd = results.Rspamd
// Add raw headers
if results.Email != nil && results.Email.RawHeaders != "" {
report.RawHeaders = &results.Email.RawHeaders
}
// Calculate overall score as mean of all category scores
categoryScores := []int{
report.Summary.DnsScore,
report.Summary.AuthenticationScore,
report.Summary.BlacklistScore,
report.Summary.ContentScore,
report.Summary.HeaderScore,
report.Summary.SpamScore,
}
var totalScore int
var categoryCount int
for _, score := range categoryScores {
totalScore += score
categoryCount++
}
if categoryCount > 0 {
report.Score = totalScore / categoryCount
} else {
report.Score = 0
}
report.Grade = ScoreToReportGrade(report.Score)
categoryGrades := []string{
string(report.Summary.DnsGrade),
string(report.Summary.AuthenticationGrade),
string(report.Summary.BlacklistGrade),
string(report.Summary.ContentGrade),
string(report.Summary.HeaderGrade),
string(report.Summary.SpamGrade),
}
if report.Score >= 100 {
hasLessThanA := false
for _, grade := range categoryGrades {
if len(grade) < 1 || grade[0] != 'A' {
hasLessThanA = true
}
}
if !hasLessThanA {
report.Grade = "A+"
}
} else {
var minusGrade byte = 0
for _, grade := range categoryGrades {
if len(grade) == 0 {
minusGrade = 255
break
} else if grade[0]-'A' > minusGrade {
minusGrade = grade[0] - 'A'
}
}
if minusGrade < 255 {
report.Grade = model.ReportGrade(string([]byte{'A' + minusGrade}))
}
}
return report
}
// buildDNSRecords converts DNS analysis results to API DNS records
func (r *ReportGenerator) buildDNSRecords(dns *DNSResults) []api.DNSRecord {
records := []api.DNSRecord{}
if dns == nil {
return records
}
// MX records
if len(dns.MXRecords) > 0 {
for _, mx := range dns.MXRecords {
status := api.Found
if !mx.Valid {
if mx.Error != "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dns.Domain,
RecordType: api.MX,
Status: status,
}
if mx.Host != "" {
value := mx.Host
record.Value = &value
}
records = append(records, record)
}
}
// SPF record
if dns.SPFRecord != nil {
status := api.Found
if !dns.SPFRecord.Valid {
if dns.SPFRecord.Record == "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dns.Domain,
RecordType: api.SPF,
Status: status,
}
if dns.SPFRecord.Record != "" {
record.Value = &dns.SPFRecord.Record
}
records = append(records, record)
}
// DKIM records
for _, dkim := range dns.DKIMRecords {
status := api.Found
if !dkim.Valid {
if dkim.Record == "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dkim.Domain,
RecordType: api.DKIM,
Status: status,
}
if dkim.Record != "" {
// Include selector in value for clarity
value := dkim.Record
record.Value = &value
}
records = append(records, record)
}
// DMARC record
if dns.DMARCRecord != nil {
status := api.Found
if !dns.DMARCRecord.Valid {
if dns.DMARCRecord.Record == "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dns.Domain,
RecordType: api.DMARC,
Status: status,
}
if dns.DMARCRecord.Record != "" {
record.Value = &dns.DMARCRecord.Record
}
records = append(records, record)
}
return records
}
// GenerateRawEmail returns the raw email message as a string
func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
if email == nil {
@ -308,3 +328,21 @@ func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
return raw
}
// GetRecommendations returns actionable recommendations based on the score
func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string {
if results == nil || results.Score == nil {
return []string{}
}
return results.Score.Recommendations
}
// GetScoreSummaryText returns a human-readable score summary
func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string {
if results == nil || results.Score == nil {
return ""
}
return r.scorer.GetScoreSummary(results.Score)
}

View file

@ -24,15 +24,16 @@ package analyzer
import (
"net/mail"
"net/textproto"
"strings"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
"github.com/google/uuid"
)
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)
if gen == nil {
t.Fatal("Expected report generator, got nil")
}
@ -52,10 +53,13 @@ func TestNewReportGenerator(t *testing.T) {
if gen.contentAnalyzer == nil {
t.Error("contentAnalyzer should not be nil")
}
if gen.scorer == nil {
t.Error("scorer should not be nil")
}
}
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)
email := createTestEmail()
@ -72,10 +76,24 @@ func TestAnalyzeEmail(t *testing.T) {
if results.Authentication == nil {
t.Error("Authentication should not be nil")
}
// SpamAssassin might be nil if headers don't exist
// DNS results should exist
// RBL results should exist
// Content results should exist
if results.Score == nil {
t.Error("Score should not be nil")
}
// Verify score is within bounds
if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 {
t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore)
}
}
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)
testID := uuid.New()
email := createTestEmail()
@ -88,17 +106,15 @@ func TestGenerateReport(t *testing.T) {
}
// Verify required fields
if report.Id == "" {
if report.Id == uuid.Nil {
t.Error("Report ID should not be empty")
}
// Convert testID to base32 for comparison
expectedTestID := utils.UUIDToBase32(testID)
if report.TestId != expectedTestID {
t.Errorf("TestId = %s, want %s", report.TestId, expectedTestID)
if report.TestId != testID {
t.Errorf("TestId = %s, want %s", report.TestId, testID)
}
if report.Score < 0 || report.Score > 100 {
if report.Score < 0 || report.Score > 10 {
t.Errorf("Score %v is out of bounds", report.Score)
}
@ -106,31 +122,48 @@ func TestGenerateReport(t *testing.T) {
t.Error("Summary should not be nil")
}
// Verify score summary (all scores are 0-100 percentages)
if len(report.Checks) == 0 {
t.Error("Checks should not be empty")
}
// Verify score summary
if report.Summary != nil {
if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 100 {
if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 {
t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore)
}
if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 100 {
if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 {
t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore)
}
if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 100 {
if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 2 {
t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore)
}
if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 100 {
if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 2 {
t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore)
}
if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 100 {
if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 1 {
t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore)
}
if report.Summary.DnsScore < 0 || report.Summary.DnsScore > 100 {
t.Errorf("DnsScore %v is out of bounds", report.Summary.DnsScore)
}
// Verify checks have required fields
for i, check := range report.Checks {
if string(check.Category) == "" {
t.Errorf("Check %d: Category should not be empty", i)
}
if check.Name == "" {
t.Errorf("Check %d: Name should not be empty", i)
}
if string(check.Status) == "" {
t.Errorf("Check %d: Status should not be empty", i)
}
if check.Message == "" {
t.Errorf("Check %d: Message should not be empty", i)
}
}
}
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)
testID := uuid.New()
email := createTestEmailWithSpamAssassin()
@ -149,8 +182,101 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
}
}
func TestBuildDNSRecords(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
dns *DNSResults
expectedCount int
expectTypes []api.DNSRecordRecordType
}{
{
name: "Nil DNS results",
dns: nil,
expectedCount: 0,
},
{
name: "Complete DNS results",
dns: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
},
SPFRecord: &SPFRecord{
Record: "v=spf1 include:_spf.example.com -all",
Valid: true,
},
DKIMRecords: []DKIMRecord{
{
Selector: "default",
Domain: "example.com",
Record: "v=DKIM1; k=rsa; p=...",
Valid: true,
},
},
DMARCRecord: &DMARCRecord{
Record: "v=DMARC1; p=quarantine",
Valid: true,
},
},
expectedCount: 4, // MX, SPF, DKIM, DMARC
expectTypes: []api.DNSRecordRecordType{api.MX, api.SPF, api.DKIM, api.DMARC},
},
{
name: "Missing records",
dns: &DNSResults{
Domain: "example.com",
SPFRecord: &SPFRecord{
Valid: false,
Error: "No SPF record found",
},
},
expectedCount: 1,
expectTypes: []api.DNSRecordRecordType{api.SPF},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
records := gen.buildDNSRecords(tt.dns)
if len(records) != tt.expectedCount {
t.Errorf("Got %d DNS records, want %d", len(records), tt.expectedCount)
}
// Verify expected types are present
if tt.expectTypes != nil {
foundTypes := make(map[api.DNSRecordRecordType]bool)
for _, record := range records {
foundTypes[record.RecordType] = true
}
for _, expectedType := range tt.expectTypes {
if !foundTypes[expectedType] {
t.Errorf("Expected DNS record type %s not found", expectedType)
}
}
}
// Verify all records have required fields
for i, record := range records {
if record.Domain == "" {
t.Errorf("Record %d: Domain should not be empty", i)
}
if string(record.RecordType) == "" {
t.Errorf("Record %d: RecordType should not be empty", i)
}
if string(record.Status) == "" {
t.Errorf("Record %d: Status should not be empty", i)
}
}
})
}
}
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)
tests := []struct {
name string
@ -190,6 +316,135 @@ func TestGenerateRawEmail(t *testing.T) {
}
}
func TestGetRecommendations(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
results *AnalysisResults
expectCount int
}{
{
name: "Nil results",
results: nil,
expectCount: 0,
},
{
name: "Results with score",
results: &AnalysisResults{
Score: &ScoringResult{
OverallScore: 5.0,
Rating: "Fair",
AuthScore: 1.5,
SpamScore: 1.0,
BlacklistScore: 1.5,
ContentScore: 0.5,
HeaderScore: 0.5,
Recommendations: []string{
"Improve authentication",
"Fix content issues",
},
},
},
expectCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recs := gen.GetRecommendations(tt.results)
if len(recs) != tt.expectCount {
t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount)
}
})
}
}
func TestGetScoreSummaryText(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
results *AnalysisResults
expectEmpty bool
expectString string
}{
{
name: "Nil results",
results: nil,
expectEmpty: true,
},
{
name: "Results with score",
results: &AnalysisResults{
Score: &ScoringResult{
OverallScore: 8.5,
Rating: "Good",
AuthScore: 2.5,
SpamScore: 1.8,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 0.7,
CategoryBreakdown: map[string]CategoryScore{
"Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
"Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
"Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
"Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
"Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
},
},
},
expectEmpty: false,
expectString: "8.5/10",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary := gen.GetScoreSummaryText(tt.results)
if tt.expectEmpty {
if summary != "" {
t.Errorf("Expected empty summary, got %q", summary)
}
} else {
if summary == "" {
t.Error("Expected non-empty summary")
}
if tt.expectString != "" && !strings.Contains(summary, tt.expectString) {
t.Errorf("Summary should contain %q, got %q", tt.expectString, summary)
}
}
})
}
}
func TestReportCategories(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
testID := uuid.New()
email := createComprehensiveTestEmail()
results := gen.AnalyzeEmail(email)
report := gen.GenerateReport(testID, results)
// Verify all check categories are present
categories := make(map[api.CheckCategory]bool)
for _, check := range report.Checks {
categories[check.Category] = true
}
expectedCategories := []api.CheckCategory{
api.Authentication,
api.Dns,
api.Headers,
}
for _, cat := range expectedCategories {
if !categories[cat] {
t.Errorf("Expected category %s not found in checks", cat)
}
}
}
// Helper functions
func createTestEmail() *EmailMessage {
@ -226,3 +481,21 @@ func createTestEmailWithSpamAssassin() *EmailMessage {
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"}
return email
}
func createComprehensiveTestEmail() *EmailMessage {
email := createTestEmailWithSpamAssassin()
// Add authentication headers
email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{
"example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass",
}
// Add HTML content
email.Parts = append(email.Parts, MessagePart{
ContentType: "text/html",
Content: "<html><body><p>Test</p><a href='https://example.com'>Link</a></body></html>",
IsHTML: true,
})
return email
}

View file

@ -1,21 +0,0 @@
# rspamd-symbols.json
This file contains rspamd symbol descriptions, embedded into the binary at compile time as a fallback when no rspamd API URL is configured.
## How to update
Fetch the latest symbols from a running rspamd instance:
```sh
curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
```
Or with docker:
```sh
docker run --rm --name rspamd --pull always rspamd/rspamd
docker exec -u 0 rspamd apt install -y curl
docker exec rspamd curl http://127.0.0.1:11334/symbols > rspamd-symbols.json
```
Then rebuild the project.

File diff suppressed because it is too large Load diff

View file

@ -1,174 +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 (
"math"
"regexp"
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/model"
)
// Default rspamd action thresholds (rspamd built-in defaults)
const (
rspamdDefaultRejectThreshold float32 = 15
rspamdDefaultAddHeaderThreshold float32 = 6
)
// RspamdAnalyzer analyzes rspamd results from email headers
type RspamdAnalyzer struct {
symbols map[string]string
}
// NewRspamdAnalyzer creates a new rspamd analyzer with optional symbol descriptions
func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer {
return &RspamdAnalyzer{symbols: symbols}
}
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult {
headers := email.GetRspamdHeaders()
if len(headers) == 0 {
return nil
}
// Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report
_, hasSpamdResult := headers["X-Spamd-Result"]
_, hasRspamdScore := headers["X-Rspamd-Score"]
if !hasSpamdResult && !hasRspamdScore {
return nil
}
result := &model.RspamdResult{
Symbols: make(map[string]model.SpamTestDetail),
}
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
report := strings.ReplaceAll(spamdResult, "; ", ";\n")
result.Report = &report
a.parseSpamdResult(spamdResult, result)
}
// Parse X-Rspamd-Score as override/fallback for score
if scoreHeader, ok := headers["X-Rspamd-Score"]; ok {
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
result.Score = float32(score)
}
}
// Parse X-Rspamd-Server
if serverHeader, ok := headers["X-Rspamd-Server"]; ok {
server := strings.TrimSpace(serverHeader)
result.Server = &server
}
// Populate symbol descriptions from the lookup map
if a.symbols != nil {
for name, sym := range result.Symbols {
if desc, ok := a.symbols[name]; ok {
sym.Description = &desc
result.Symbols[name] = sym
}
}
}
// Derive IsSpam from score vs reject threshold.
if result.Threshold > 0 {
result.IsSpam = result.Score >= result.Threshold
} else {
result.IsSpam = result.Score >= rspamdDefaultAddHeaderThreshold
}
return result
}
// parseSpamdResult parses the X-Spamd-Result header
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdResult) {
// Extract score and threshold from the first line
// e.g. "default: False [-3.91 / 15.00]"
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 2 {
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
result.Score = float32(score)
}
if threshold, err := strconv.ParseFloat(matches[2], 64); err == nil {
result.Threshold = float32(threshold)
// No threshold? use default AddHeaderThreshold
if result.Threshold <= 0 {
result.Threshold = rspamdDefaultAddHeaderThreshold
}
}
}
// Parse is_spam from header (before we may get action from X-Rspamd-Action)
firstLine := strings.SplitN(header, ";", 2)[0]
if strings.Contains(firstLine, ": True") || strings.Contains(firstLine, ": true") {
result.IsSpam = true
}
// Parse symbols: SYMBOL(score)[params]
// Each symbol entry is separated by ";", so within each part we use a
// greedy match to capture params that may contain nested brackets.
symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`)
for _, part := range strings.Split(header, ";") {
part = strings.TrimSpace(part)
matches := symbolRe.FindStringSubmatch(part)
if len(matches) > 2 {
name := matches[1]
score, _ := strconv.ParseFloat(matches[2], 64)
sym := model.SpamTestDetail{
Name: name,
Score: float32(score),
}
if len(matches) > 3 && matches[3] != "" {
params := matches[3]
sym.Params = &params
}
result.Symbols[name] = sym
}
}
}
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
func (a *RspamdAnalyzer) CalculateRspamdScore(result *model.RspamdResult) (int, string) {
if result == nil {
return 100, "" // rspamd not installed
}
threshold := result.Threshold
percentage := 100 - int(math.Round(float64(result.Score*100/(2*threshold))))
if percentage > 100 {
return 100, "A+"
} else if percentage < 0 {
return 0, "F"
}
// Linear scale between 0 and threshold
return percentage, ScoreToGrade(percentage)
}

View file

@ -1,105 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
_ "embed"
"encoding/json"
"io"
"log"
"net/http"
"strings"
"time"
)
//go:embed rspamd-symbols.json
var embeddedRspamdSymbols []byte
// rspamdSymbolGroup represents a group of rspamd symbols from the API/embedded JSON.
type rspamdSymbolGroup struct {
Group string `json:"group"`
Rules []rspamdSymbolEntry `json:"rules"`
}
// rspamdSymbolEntry represents a single rspamd symbol entry.
type rspamdSymbolEntry struct {
Symbol string `json:"symbol"`
Description string `json:"description"`
Weight float64 `json:"weight"`
}
// parseRspamdSymbolsJSON parses the rspamd symbols JSON into a name->description map.
func parseRspamdSymbolsJSON(data []byte) map[string]string {
var groups []rspamdSymbolGroup
if err := json.Unmarshal(data, &groups); err != nil {
log.Printf("Failed to parse rspamd symbols JSON: %v", err)
return nil
}
symbols := make(map[string]string, len(groups)*10)
for _, g := range groups {
for _, r := range g.Rules {
if r.Description != "" {
symbols[r.Symbol] = r.Description
}
}
}
return symbols
}
// LoadRspamdSymbols loads rspamd symbol descriptions.
// If apiURL is non-empty, it fetches from the rspamd API first, falling back to the embedded list on error.
func LoadRspamdSymbols(apiURL string) map[string]string {
if apiURL != "" {
if symbols := fetchRspamdSymbols(apiURL); symbols != nil {
return symbols
}
log.Printf("Failed to fetch rspamd symbols from %s, using embedded list", apiURL)
}
return parseRspamdSymbolsJSON(embeddedRspamdSymbols)
}
// fetchRspamdSymbols fetches symbol descriptions from the rspamd API.
func fetchRspamdSymbols(apiURL string) map[string]string {
url := strings.TrimRight(apiURL, "/") + "/symbols"
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
log.Printf("Error fetching rspamd symbols: %v", err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("rspamd API returned status %d", resp.StatusCode)
return nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("Error reading rspamd symbols response: %v", err)
return nil
}
return parseRspamdSymbolsJSON(body)
}

View file

@ -1,414 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"bytes"
"net/mail"
"testing"
"git.happydns.org/happyDeliver/internal/model"
)
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
analyzer := NewRspamdAnalyzer(nil)
email := &EmailMessage{Header: make(mail.Header)}
result := analyzer.AnalyzeRspamd(email)
if result != nil {
t.Errorf("Expected nil for email without rspamd headers, got %+v", result)
}
}
func TestParseSpamdResult(t *testing.T) {
tests := []struct {
name string
header string
expectedScore float32
expectedThreshold float32
expectedIsSpam bool
expectedSymbols map[string]float32
expectedSymParams map[string]string
}{
{
name: "Clean email negative score",
header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]",
expectedScore: -3.91,
expectedThreshold: 15.00,
expectedIsSpam: false,
expectedSymbols: map[string]float32{
"DATE_IN_PAST": 0.10,
"ALL_TRUSTED": -1.00,
},
expectedSymParams: map[string]string{
"ALL_TRUSTED": "trusted",
},
},
{
name: "Spam email True flag",
header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)",
expectedScore: 16.50,
expectedThreshold: 15.00,
expectedIsSpam: true,
expectedSymbols: map[string]float32{
"BAYES_99": 5.00,
"SPOOFED_SENDER": 3.50,
},
expectedSymParams: map[string]string{
"BAYES_99": "1.00",
},
},
{
name: "Zero threshold uses default",
header: "default: False [1.00 / 0.00]",
expectedScore: 1.00,
expectedThreshold: rspamdDefaultAddHeaderThreshold,
expectedIsSpam: false,
expectedSymbols: map[string]float32{},
},
{
name: "Symbol without params",
header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)",
expectedScore: 2.00,
expectedThreshold: 15.00,
expectedIsSpam: false,
expectedSymbols: map[string]float32{
"MISSING_DATE": 1.00,
},
},
{
name: "Case-insensitive true flag",
header: "default: true [8.00 / 6.00]",
expectedScore: 8.00,
expectedThreshold: 6.00,
expectedIsSpam: true,
expectedSymbols: map[string]float32{},
},
{
name: "Zero threshold with symbols containing nested brackets in params",
header: "default: False [0.90 / 0.00];\n" +
"\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" +
"\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" +
"\tMIME_TRACE(0.00)[0:+,1:+,2:~]",
expectedScore: 0.90,
expectedThreshold: rspamdDefaultAddHeaderThreshold,
expectedIsSpam: false,
expectedSymbols: map[string]float32{
"ARC_REJECT": 1.00,
"MIME_GOOD": -0.10,
"MIME_TRACE": 0.00,
},
expectedSymParams: map[string]string{
"ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}",
"MIME_GOOD": "multipart/alternative,text/plain",
"MIME_TRACE": "0:+,1:+,2:~",
},
},
}
analyzer := NewRspamdAnalyzer(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &model.RspamdResult{
Symbols: make(map[string]model.SpamTestDetail),
}
analyzer.parseSpamdResult(tt.header, result)
if result.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
}
if result.Threshold != tt.expectedThreshold {
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
}
if result.IsSpam != tt.expectedIsSpam {
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
}
for symName, expectedScore := range tt.expectedSymbols {
sym, ok := result.Symbols[symName]
if !ok {
t.Errorf("Symbol %s not found", symName)
continue
}
if sym.Score != expectedScore {
t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore)
}
}
for symName, expectedParam := range tt.expectedSymParams {
sym, ok := result.Symbols[symName]
if !ok {
t.Errorf("Symbol %s not found for params check", symName)
continue
}
if sym.Params == nil {
t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam)
} else if *sym.Params != expectedParam {
t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam)
}
}
})
}
}
func TestAnalyzeRspamd(t *testing.T) {
tests := []struct {
name string
headers map[string]string
expectedScore float32
expectedThreshold float32
expectedIsSpam bool
expectedServer *string
expectedSymCount int
}{
{
name: "Full headers clean email",
headers: map[string]string{
"X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]",
"X-Rspamd-Score": "-3.91",
"X-Rspamd-Server": "mail.example.com",
},
expectedScore: -3.91,
expectedThreshold: 15.00,
expectedIsSpam: false,
expectedServer: func() *string { s := "mail.example.com"; return &s }(),
expectedSymCount: 1,
},
{
name: "X-Rspamd-Score overrides spamd result score",
headers: map[string]string{
"X-Spamd-Result": "default: False [2.00 / 15.00]",
"X-Rspamd-Score": "3.50",
},
expectedScore: 3.50,
expectedThreshold: 15.00,
expectedIsSpam: false,
},
{
name: "Spam email above threshold",
headers: map[string]string{
"X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)",
"X-Rspamd-Score": "16.00",
},
expectedScore: 16.00,
expectedThreshold: 15.00,
expectedIsSpam: true,
expectedSymCount: 1,
},
{
name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold",
headers: map[string]string{
"X-Rspamd-Score": "2.00",
},
expectedScore: 2.00,
expectedIsSpam: false,
},
{
name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold",
headers: map[string]string{
"X-Rspamd-Score": "7.00",
},
expectedScore: 7.00,
expectedIsSpam: true,
},
{
name: "Server header is trimmed",
headers: map[string]string{
"X-Rspamd-Score": "1.00",
"X-Rspamd-Server": " rspamd-01 ",
},
expectedScore: 1.00,
expectedServer: func() *string { s := "rspamd-01"; return &s }(),
},
}
analyzer := NewRspamdAnalyzer(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{Header: make(mail.Header)}
for k, v := range tt.headers {
email.Header[k] = []string{v}
}
result := analyzer.AnalyzeRspamd(email)
if result == nil {
t.Fatal("Expected non-nil result")
}
if result.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
}
if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
}
if result.IsSpam != tt.expectedIsSpam {
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
}
if tt.expectedServer != nil {
if result.Server == nil {
t.Errorf("Server = nil, want %q", *tt.expectedServer)
} else if *result.Server != *tt.expectedServer {
t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
}
}
if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
}
})
}
}
func TestCalculateRspamdScore(t *testing.T) {
tests := []struct {
name string
result *model.RspamdResult
expectedScore int
expectedGrade string
}{
{
name: "Nil result (rspamd not installed)",
result: nil,
expectedScore: 100,
expectedGrade: "",
},
{
name: "Score well below threshold",
result: &model.RspamdResult{
Score: -3.91,
Threshold: 15.00,
},
expectedScore: 100,
expectedGrade: "A+",
},
{
name: "Score at zero",
result: &model.RspamdResult{
Score: 0,
Threshold: 15.00,
},
// 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
expectedScore: 100,
expectedGrade: "A",
},
{
name: "Score at threshold (half of 2*threshold)",
result: &model.RspamdResult{
Score: 15.00,
Threshold: 15.00,
},
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
expectedScore: 50,
},
{
name: "Score above 2*threshold",
result: &model.RspamdResult{
Score: 31.00,
Threshold: 15.00,
},
expectedScore: 0,
expectedGrade: "F",
},
{
name: "Score exactly at 2*threshold",
result: &model.RspamdResult{
Score: 30.00,
Threshold: 15.00,
},
// 100 - round(30*100/30) = 100 - 100 = 0
expectedScore: 0,
expectedGrade: "F",
},
}
analyzer := NewRspamdAnalyzer(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score, grade := analyzer.CalculateRspamdScore(tt.result)
if score != tt.expectedScore {
t.Errorf("Score = %d, want %d", score, tt.expectedScore)
}
if tt.expectedGrade != "" && grade != tt.expectedGrade {
t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade)
}
})
}
}
const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00];
BAYES_HAM(-3.00)[99%];
RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from];
R_DKIM_ALLOW(-0.20)[example.com:s=dkim];
FROM_HAS_DN(0.00)[];
MIME_GOOD(-0.10)[text/plain];
X-Rspamd-Score: -3.91
X-Rspamd-Server: rspamd-01.example.com
Date: Mon, 09 Mar 2026 10:00:00 +0000
From: sender@example.com
To: test@happydomain.org
Subject: Test email
Message-ID: <test123@example.com>
MIME-Version: 1.0
Content-Type: text/plain
Hello world`
func TestAnalyzeRspamdRealEmail(t *testing.T) {
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders))
if err != nil {
t.Fatalf("Failed to parse email: %v", err)
}
analyzer := NewRspamdAnalyzer(nil)
result := analyzer.AnalyzeRspamd(email)
if result == nil {
t.Fatal("Expected non-nil result")
}
if result.IsSpam {
t.Error("Expected IsSpam=false")
}
if result.Score != -3.91 {
t.Errorf("Score = %v, want -3.91", result.Score)
}
if result.Threshold != 15.00 {
t.Errorf("Threshold = %v, want 15.00", result.Threshold)
}
if result.Server == nil || *result.Server != "rspamd-01.example.com" {
t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server)
}
expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"}
for _, sym := range expectedSymbols {
if _, ok := result.Symbols[sym]; !ok {
t.Errorf("Symbol %s not found", sym)
}
}
score, _ := analyzer.CalculateRspamdScore(result)
if score != 100 {
t.Errorf("CalculateRspamdScore = %d, want 100", score)
}
}

View file

@ -22,80 +22,524 @@
package analyzer
import (
"git.happydns.org/happyDeliver/internal/model"
"fmt"
"strings"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// ScoreToGrade converts a percentage score (0-100) to a letter grade
func ScoreToGrade(score int) string {
// DeliverabilityScorer aggregates all analysis results and computes overall score
type DeliverabilityScorer struct{}
// NewDeliverabilityScorer creates a new deliverability scorer
func NewDeliverabilityScorer() *DeliverabilityScorer {
return &DeliverabilityScorer{}
}
// ScoringResult represents the complete scoring result
type ScoringResult struct {
OverallScore float32
Rating string // Excellent, Good, Fair, Poor, Critical
AuthScore float32
SpamScore float32
BlacklistScore float32
ContentScore float32
HeaderScore float32
Recommendations []string
CategoryBreakdown map[string]CategoryScore
}
// CategoryScore represents score breakdown for a category
type CategoryScore struct {
Score float32
MaxScore float32
Percentage float32
Status string // Pass, Warn, Fail
}
// CalculateScore computes the overall deliverability score from all analyzers
func (s *DeliverabilityScorer) CalculateScore(
authResults *api.AuthenticationResults,
spamResult *SpamAssassinResult,
rblResults *RBLResults,
contentResults *ContentResults,
email *EmailMessage,
) *ScoringResult {
result := &ScoringResult{
CategoryBreakdown: make(map[string]CategoryScore),
Recommendations: []string{},
}
// Calculate individual scores
result.AuthScore = s.GetAuthenticationScore(authResults)
spamAnalyzer := NewSpamAssassinAnalyzer()
result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult)
rblChecker := NewRBLChecker(10*time.Second, DefaultRBLs)
result.BlacklistScore = rblChecker.GetBlacklistScore(rblResults)
contentAnalyzer := NewContentAnalyzer(10 * time.Second)
result.ContentScore = contentAnalyzer.GetContentScore(contentResults)
// Calculate header quality score
result.HeaderScore = s.calculateHeaderScore(email)
// Calculate overall score (out of 10)
result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
// Ensure score is within bounds
if result.OverallScore > 10.0 {
result.OverallScore = 10.0
}
if result.OverallScore < 0.0 {
result.OverallScore = 0.0
}
// Determine rating
result.Rating = s.determineRating(result.OverallScore)
// Build category breakdown
result.CategoryBreakdown["Authentication"] = CategoryScore{
Score: result.AuthScore,
MaxScore: 3.0,
Percentage: (result.AuthScore / 3.0) * 100,
Status: s.getCategoryStatus(result.AuthScore, 3.0),
}
result.CategoryBreakdown["Spam Filters"] = CategoryScore{
Score: result.SpamScore,
MaxScore: 2.0,
Percentage: (result.SpamScore / 2.0) * 100,
Status: s.getCategoryStatus(result.SpamScore, 2.0),
}
result.CategoryBreakdown["Blacklists"] = CategoryScore{
Score: result.BlacklistScore,
MaxScore: 2.0,
Percentage: (result.BlacklistScore / 2.0) * 100,
Status: s.getCategoryStatus(result.BlacklistScore, 2.0),
}
result.CategoryBreakdown["Content Quality"] = CategoryScore{
Score: result.ContentScore,
MaxScore: 2.0,
Percentage: (result.ContentScore / 2.0) * 100,
Status: s.getCategoryStatus(result.ContentScore, 2.0),
}
result.CategoryBreakdown["Email Structure"] = CategoryScore{
Score: result.HeaderScore,
MaxScore: 1.0,
Percentage: (result.HeaderScore / 1.0) * 100,
Status: s.getCategoryStatus(result.HeaderScore, 1.0),
}
// Generate recommendations
result.Recommendations = s.generateRecommendations(result)
return result
}
// calculateHeaderScore evaluates email structural quality (0-1 point)
func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 {
if email == nil {
return 0.0
}
score := float32(0.0)
requiredHeaders := 0
presentHeaders := 0
// Check required headers (RFC 5322)
headers := map[string]bool{
"From": false,
"Date": false,
"Message-ID": false,
}
for header := range headers {
requiredHeaders++
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
headers[header] = true
presentHeaders++
}
}
// Score based on required headers (0.4 points)
if presentHeaders == requiredHeaders {
score += 0.4
} else {
score += 0.4 * (float32(presentHeaders) / float32(requiredHeaders))
}
// Check recommended headers (0.3 points)
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
recommendedPresent := 0
for _, header := range recommendedHeaders {
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
recommendedPresent++
}
}
score += 0.3 * (float32(recommendedPresent) / float32(len(recommendedHeaders)))
// Check for proper MIME structure (0.2 points)
if len(email.Parts) > 0 {
score += 0.2
}
// Check Message-ID format (0.1 points)
if messageID := email.GetHeaderValue("Message-ID"); messageID != "" {
if s.isValidMessageID(messageID) {
score += 0.1
}
}
// Ensure score doesn't exceed 1.0
if score > 1.0 {
score = 1.0
}
return score
}
// isValidMessageID checks if a Message-ID has proper format
func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool {
// Basic check: should be in format <...@...>
if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") {
return false
}
// Remove angle brackets
messageID = strings.TrimPrefix(messageID, "<")
messageID = strings.TrimSuffix(messageID, ">")
// Should contain @ symbol
if !strings.Contains(messageID, "@") {
return false
}
parts := strings.Split(messageID, "@")
if len(parts) != 2 {
return false
}
// Both parts should be non-empty
return len(parts[0]) > 0 && len(parts[1]) > 0
}
// determineRating determines the rating based on overall score
func (s *DeliverabilityScorer) determineRating(score float32) string {
switch {
case score > 100:
return "A+"
case score >= 95:
return "A"
case score >= 85:
return "B"
case score >= 75:
return "C"
case score >= 65:
return "D"
case score >= 50:
return "E"
case score >= 9.0:
return "Excellent"
case score >= 7.0:
return "Good"
case score >= 5.0:
return "Fair"
case score >= 3.0:
return "Poor"
default:
return "F"
return "Critical"
}
}
// ScoreToGradeKind converts a percentage score (0-100) to a letter grade, be kind in gradation
func ScoreToGradeKind(score int) string {
// getCategoryStatus determines status for a category
func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string {
percentage := (score / maxScore) * 100
switch {
case score > 100:
return "A+"
case score >= 90:
return "A"
case score >= 80:
return "B"
case score >= 60:
return "C"
case score >= 45:
return "D"
case score >= 30:
return "E"
case percentage >= 80.0:
return "Pass"
case percentage >= 50.0:
return "Warn"
default:
return "F"
return "Fail"
}
}
// ScoreToReportGrade converts a percentage score to an model.ReportGrade
func ScoreToReportGrade(score int) model.ReportGrade {
return model.ReportGrade(ScoreToGrade(score))
// generateRecommendations creates actionable recommendations based on scores
func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string {
var recommendations []string
// Authentication recommendations
if result.AuthScore < 2.0 {
recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records")
} else if result.AuthScore < 3.0 {
recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability")
}
// Spam recommendations
if result.SpamScore < 1.0 {
recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns")
} else if result.SpamScore < 1.5 {
recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues")
}
// Blacklist recommendations
if result.BlacklistScore < 1.0 {
recommendations = append(recommendations, "🚫 Your IP is listed on blacklists - take immediate action to delist and improve sender reputation")
} else if result.BlacklistScore < 2.0 {
recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices")
}
// Content recommendations
if result.ContentScore < 1.0 {
recommendations = append(recommendations, "📝 Improve email content quality: fix broken links, add alt text to images, and ensure proper HTML structure")
} else if result.ContentScore < 1.5 {
recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency")
}
// Header recommendations
if result.HeaderScore < 0.5 {
recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)")
} else if result.HeaderScore < 1.0 {
recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present")
}
// Overall recommendations based on rating
if result.Rating == "Excellent" {
recommendations = append(recommendations, "✅ Your email has excellent deliverability - maintain current practices")
} else if result.Rating == "Critical" {
recommendations = append(recommendations, "🆘 Critical issues detected - emails will likely be rejected or marked as spam")
}
return recommendations
}
// gradeRank returns a numeric rank for a grade (lower = worse)
func gradeRank(grade string) int {
switch grade {
case "A++":
return 7
case "A+":
return 6
case "A":
return 5
case "B":
return 4
case "C":
return 3
case "D":
return 2
case "E":
return 1
default:
return 0
// GenerateHeaderChecks creates checks for email header quality
func (s *DeliverabilityScorer) GenerateHeaderChecks(email *EmailMessage) []api.Check {
var checks []api.Check
if email == nil {
return checks
}
// Required headers check
checks = append(checks, s.generateRequiredHeadersCheck(email))
// Recommended headers check
checks = append(checks, s.generateRecommendedHeadersCheck(email))
// Message-ID check
checks = append(checks, s.generateMessageIDCheck(email))
// MIME structure check
checks = append(checks, s.generateMIMEStructureCheck(email))
return checks
}
// MinGrade returns the minimal (worse) grade between the two given grades
func MinGrade(a, b string) string {
if gradeRank(a) <= gradeRank(b) {
return a
// generateRequiredHeadersCheck checks for required RFC 5322 headers
func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Required Headers",
}
return b
requiredHeaders := []string{"From", "Date", "Message-ID"}
missing := []string{}
for _, header := range requiredHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
missing = append(missing, header)
}
}
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 0.4
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All required headers are present"
check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
} else {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
check.Details = &details
}
return check
}
// generateRecommendedHeadersCheck checks for recommended headers
func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Recommended Headers",
}
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
missing := []string{}
for _, header := range recommendedHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
missing = append(missing, header)
}
}
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All recommended headers are present"
check.Advice = api.PtrTo("Your email includes all recommended headers")
} else if len(missing) < len(recommendedHeaders) {
check.Status = api.CheckStatusWarn
check.Score = 0.15
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
check.Details = &details
} else {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Missing all recommended headers"
check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
}
return check
}
// generateMessageIDCheck validates Message-ID header
func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Message-ID Format",
}
messageID := email.GetHeaderValue("Message-ID")
if messageID == "" {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Message = "Message-ID header is missing"
check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
} else if !s.isValidMessageID(messageID) {
check.Status = api.CheckStatusWarn
check.Score = 0.05
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Message-ID format is invalid"
check.Advice = api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>")
check.Details = &messageID
} else {
check.Status = api.CheckStatusPass
check.Score = 0.1
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Message-ID is properly formatted"
check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
check.Details = &messageID
}
return check
}
// generateMIMEStructureCheck validates MIME structure
func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "MIME Structure",
}
if len(email.Parts) == 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "No MIME parts detected"
check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
} else {
check.Status = api.CheckStatusPass
check.Score = 0.2
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
check.Advice = api.PtrTo("Your email has proper MIME structure")
// Add details about parts
partTypes := []string{}
for _, part := range email.Parts {
if part.ContentType != "" {
partTypes = append(partTypes, part.ContentType)
}
}
if len(partTypes) > 0 {
details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", "))
check.Details = &details
}
}
return check
}
// GetScoreSummary generates a human-readable summary of the score
func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string {
var summary strings.Builder
summary.WriteString(fmt.Sprintf("Overall Score: %.1f/10 (%s)\n\n", result.OverallScore, result.Rating))
summary.WriteString("Category Breakdown:\n")
summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/3.0 (%.0f%%) - %s\n",
result.AuthScore, result.CategoryBreakdown["Authentication"].Percentage, result.CategoryBreakdown["Authentication"].Status))
summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/2.0 (%.0f%%) - %s\n",
result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status))
summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/2.0 (%.0f%%) - %s\n",
result.BlacklistScore, result.CategoryBreakdown["Blacklists"].Percentage, result.CategoryBreakdown["Blacklists"].Status))
summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/2.0 (%.0f%%) - %s\n",
result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status))
summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/1.0 (%.0f%%) - %s\n",
result.HeaderScore, result.CategoryBreakdown["Email Structure"].Percentage, result.CategoryBreakdown["Email Structure"].Status))
if len(result.Recommendations) > 0 {
summary.WriteString("\nRecommendations:\n")
for _, rec := range result.Recommendations {
summary.WriteString(fmt.Sprintf(" %s\n", rec))
}
}
return summary.String()
}
// GetAuthenticationScore calculates the authentication score (0-3 points)
func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 {
var score float32 = 0.0
// SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail
if results.Spf != nil {
switch results.Spf.Result {
case api.AuthResultResultPass:
score += 1.0
case api.AuthResultResultNeutral, api.AuthResultResultSoftfail:
score += 0.5
}
}
// DKIM: 1 point for at least one pass
if results.Dkim != nil && len(*results.Dkim) > 0 {
for _, dkim := range *results.Dkim {
if dkim.Result == api.AuthResultResultPass {
score += 1.0
break
}
}
}
// DMARC: 1 point for pass
if results.Dmarc != nil {
switch results.Dmarc.Result {
case api.AuthResultResultPass:
score += 1.0
}
}
// Cap at 3 points maximum
if score > 3.0 {
score = 3.0
}
return score
}

View file

@ -0,0 +1,762 @@
// 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 (
"net/mail"
"net/textproto"
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewDeliverabilityScorer(t *testing.T) {
scorer := NewDeliverabilityScorer()
if scorer == nil {
t.Fatal("Expected scorer, got nil")
}
}
func TestIsValidMessageID(t *testing.T) {
tests := []struct {
name string
messageID string
expected bool
}{
{
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expected: true,
},
{
name: "Valid with UUID",
messageID: "<550e8400-e29b-41d4-a716-446655440000@example.com>",
expected: true,
},
{
name: "Missing angle brackets",
messageID: "abc123@example.com",
expected: false,
},
{
name: "Missing @ symbol",
messageID: "<abc123example.com>",
expected: false,
},
{
name: "Multiple @ symbols",
messageID: "<abc@123@example.com>",
expected: false,
},
{
name: "Empty local part",
messageID: "<@example.com>",
expected: false,
},
{
name: "Empty domain part",
messageID: "<abc123@>",
expected: false,
},
{
name: "Empty",
messageID: "",
expected: false,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.isValidMessageID(tt.messageID)
if result != tt.expected {
t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected)
}
})
}
}
func TestCalculateHeaderScore(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minScore float32
maxScore float32
}{
{
name: "Nil email",
email: nil,
minScore: 0.0,
maxScore: 0.0,
},
{
name: "Perfect headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 0.7,
maxScore: 1.0,
},
{
name: "Missing required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Subject": "Test",
}),
},
minScore: 0.0,
maxScore: 0.4,
},
{
name: "Required only, no recommended",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 0.4,
maxScore: 0.8,
},
{
name: "Invalid Message-ID format",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "invalid-message-id",
"Subject": "Test",
"To": "recipient@example.com",
"Reply-To": "reply@example.com",
}),
MessageID: "invalid-message-id",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 0.7,
maxScore: 1.0,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := scorer.calculateHeaderScore(tt.email)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
})
}
}
func TestDetermineRating(t *testing.T) {
tests := []struct {
name string
score float32
expected string
}{
{name: "Excellent - 10.0", score: 10.0, expected: "Excellent"},
{name: "Excellent - 9.5", score: 9.5, expected: "Excellent"},
{name: "Excellent - 9.0", score: 9.0, expected: "Excellent"},
{name: "Good - 8.5", score: 8.5, expected: "Good"},
{name: "Good - 7.0", score: 7.0, expected: "Good"},
{name: "Fair - 6.5", score: 6.5, expected: "Fair"},
{name: "Fair - 5.0", score: 5.0, expected: "Fair"},
{name: "Poor - 4.5", score: 4.5, expected: "Poor"},
{name: "Poor - 3.0", score: 3.0, expected: "Poor"},
{name: "Critical - 2.5", score: 2.5, expected: "Critical"},
{name: "Critical - 0.0", score: 0.0, expected: "Critical"},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.determineRating(tt.score)
if result != tt.expected {
t.Errorf("determineRating(%v) = %q, want %q", tt.score, result, tt.expected)
}
})
}
}
func TestGetCategoryStatus(t *testing.T) {
tests := []struct {
name string
score float32
maxScore float32
expected string
}{
{name: "Pass - 100%", score: 3.0, maxScore: 3.0, expected: "Pass"},
{name: "Pass - 90%", score: 2.7, maxScore: 3.0, expected: "Pass"},
{name: "Pass - 80%", score: 2.4, maxScore: 3.0, expected: "Pass"},
{name: "Warn - 75%", score: 2.25, maxScore: 3.0, expected: "Warn"},
{name: "Warn - 50%", score: 1.5, maxScore: 3.0, expected: "Warn"},
{name: "Fail - 40%", score: 1.2, maxScore: 3.0, expected: "Fail"},
{name: "Fail - 0%", score: 0.0, maxScore: 3.0, expected: "Fail"},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.getCategoryStatus(tt.score, tt.maxScore)
if result != tt.expected {
t.Errorf("getCategoryStatus(%v, %v) = %q, want %q", tt.score, tt.maxScore, result, tt.expected)
}
})
}
}
func TestCalculateScore(t *testing.T) {
tests := []struct {
name string
authResults *api.AuthenticationResults
spamResult *SpamAssassinResult
rblResults *RBLResults
contentResults *ContentResults
email *EmailMessage
minScore float32
maxScore float32
expectedRating string
}{
{
name: "Perfect email",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{Result: api.AuthResultResultPass},
},
spamResult: &SpamAssassinResult{
Score: -1.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{IP: "192.0.2.1", Listed: false},
},
},
contentResults: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: true,
TextPlainRatio: 0.8,
ImageTextRatio: 3.0,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
MessageID: "<abc123@example.com>",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 9.0,
maxScore: 10.0,
expectedRating: "Excellent",
},
{
name: "Poor email - auth issues",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultFail},
Dkim: &[]api.AuthResult{},
Dmarc: nil,
},
spamResult: &SpamAssassinResult{
Score: 8.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{
IP: "192.0.2.1",
RBL: "zen.spamhaus.org",
Listed: true,
},
},
ListedCount: 1,
},
contentResults: &ContentResults{
HTMLValid: false,
Links: []LinkCheck{{Valid: true, Status: 404}},
HasUnsubscribe: false,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
}),
},
minScore: 0.0,
maxScore: 5.0,
expectedRating: "Poor",
},
{
name: "Average email",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: nil,
},
spamResult: &SpamAssassinResult{
Score: 4.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{IP: "192.0.2.1", Listed: false},
},
},
contentResults: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
HasUnsubscribe: false,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 6.0,
maxScore: 9.0,
expectedRating: "Good",
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.CalculateScore(
tt.authResults,
tt.spamResult,
tt.rblResults,
tt.contentResults,
tt.email,
)
if result == nil {
t.Fatal("Expected result, got nil")
}
// Check overall score
if result.OverallScore < tt.minScore || result.OverallScore > tt.maxScore {
t.Errorf("OverallScore = %v, want between %v and %v", result.OverallScore, tt.minScore, tt.maxScore)
}
// Check rating
if result.Rating != tt.expectedRating {
t.Errorf("Rating = %q, want %q", result.Rating, tt.expectedRating)
}
// Verify score is within bounds
if result.OverallScore < 0.0 || result.OverallScore > 10.0 {
t.Errorf("OverallScore %v is out of bounds [0.0, 10.0]", result.OverallScore)
}
// Verify category breakdown exists
if len(result.CategoryBreakdown) != 5 {
t.Errorf("Expected 5 categories, got %d", len(result.CategoryBreakdown))
}
// Verify recommendations exist
if len(result.Recommendations) == 0 && result.Rating != "Excellent" {
t.Error("Expected recommendations for non-excellent rating")
}
// Verify category scores add up to overall score
totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
if totalCategoryScore < result.OverallScore-0.01 || totalCategoryScore > result.OverallScore+0.01 {
t.Errorf("Category scores sum (%.2f) doesn't match overall score (%.2f)",
totalCategoryScore, result.OverallScore)
}
})
}
}
func TestGenerateRecommendations(t *testing.T) {
tests := []struct {
name string
result *ScoringResult
expectedMinCount int
shouldContainKeyword string
}{
{
name: "Excellent - minimal recommendations",
result: &ScoringResult{
OverallScore: 9.5,
Rating: "Excellent",
AuthScore: 3.0,
SpamScore: 2.0,
BlacklistScore: 2.0,
ContentScore: 2.0,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "Excellent",
},
{
name: "Critical - many recommendations",
result: &ScoringResult{
OverallScore: 1.0,
Rating: "Critical",
AuthScore: 0.5,
SpamScore: 0.0,
BlacklistScore: 0.0,
ContentScore: 0.3,
HeaderScore: 0.2,
},
expectedMinCount: 5,
shouldContainKeyword: "Critical",
},
{
name: "Poor authentication",
result: &ScoringResult{
OverallScore: 5.0,
Rating: "Fair",
AuthScore: 1.5,
SpamScore: 2.0,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "authentication",
},
{
name: "Blacklist issues",
result: &ScoringResult{
OverallScore: 4.0,
Rating: "Poor",
AuthScore: 3.0,
SpamScore: 2.0,
BlacklistScore: 0.5,
ContentScore: 1.5,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "blacklist",
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recommendations := scorer.generateRecommendations(tt.result)
if len(recommendations) < tt.expectedMinCount {
t.Errorf("Got %d recommendations, want at least %d", len(recommendations), tt.expectedMinCount)
}
// Check if expected keyword appears in any recommendation
found := false
for _, rec := range recommendations {
if strings.Contains(strings.ToLower(rec), strings.ToLower(tt.shouldContainKeyword)) {
found = true
break
}
}
if !found {
t.Errorf("No recommendation contains keyword %q. Recommendations: %v",
tt.shouldContainKeyword, recommendations)
}
})
}
}
func TestGenerateRequiredHeadersCheck(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "All required headers present",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
From: &mail.Address{Address: "sender@example.com"},
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.4,
},
{
name: "Missing all required headers",
email: &EmailMessage{
Header: make(mail.Header),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
{
name: "Missing some required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
}),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := scorer.generateRequiredHeadersCheck(tt.email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
}
})
}
}
func TestGenerateMessageIDCheck(t *testing.T) {
tests := []struct {
name string
messageID string
expectedStatus api.CheckStatus
}{
{
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expectedStatus: api.CheckStatusPass,
},
{
name: "Invalid Message-ID format",
messageID: "invalid-message-id",
expectedStatus: api.CheckStatusWarn,
},
{
name: "Missing Message-ID",
messageID: "",
expectedStatus: api.CheckStatusFail,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Message-ID": tt.messageID,
}),
}
check := scorer.generateMessageIDCheck(email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
}
})
}
}
func TestGenerateMIMEStructureCheck(t *testing.T) {
tests := []struct {
name string
parts []MessagePart
expectedStatus api.CheckStatus
}{
{
name: "With MIME parts",
parts: []MessagePart{
{ContentType: "text/plain", Content: "test"},
{ContentType: "text/html", Content: "<p>test</p>"},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No MIME parts",
parts: []MessagePart{},
expectedStatus: api.CheckStatusWarn,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: make(mail.Header),
Parts: tt.parts,
}
check := scorer.generateMIMEStructureCheck(email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateHeaderChecks(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minChecks int
}{
{
name: "Nil email",
email: nil,
minChecks: 0,
},
{
name: "Complete email",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minChecks: 4, // Required, Recommended, Message-ID, MIME
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := scorer.GenerateHeaderChecks(tt.email)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Headers category
for _, check := range checks {
if check.Category != api.Headers {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers)
}
}
})
}
}
func TestGetScoreSummary(t *testing.T) {
result := &ScoringResult{
OverallScore: 8.5,
Rating: "Good",
AuthScore: 2.5,
SpamScore: 1.8,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 0.7,
CategoryBreakdown: map[string]CategoryScore{
"Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
"Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
"Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
"Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
"Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
},
Recommendations: []string{
"Improve content quality",
"Add more headers",
},
}
scorer := NewDeliverabilityScorer()
summary := scorer.GetScoreSummary(result)
// Check that summary contains key information
if !strings.Contains(summary, "8.5") {
t.Error("Summary should contain overall score")
}
if !strings.Contains(summary, "Good") {
t.Error("Summary should contain rating")
}
if !strings.Contains(summary, "Authentication") {
t.Error("Summary should contain category names")
}
if !strings.Contains(summary, "Recommendations") {
t.Error("Summary should contain recommendations section")
}
}
// Helper function to create mail.Header with specific fields
func createHeaderWithFields(fields map[string]string) mail.Header {
header := make(mail.Header)
for key, value := range fields {
if value != "" {
// Use canonical MIME header key format
canonicalKey := textproto.CanonicalMIMEHeaderKey(key)
header[canonicalKey] = []string{value}
}
}
return header
}

View file

@ -22,13 +22,12 @@
package analyzer
import (
"math"
"fmt"
"regexp"
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
@ -39,34 +38,44 @@ func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
return &SpamAssassinAnalyzer{}
}
// SpamAssassinResult represents parsed SpamAssassin results
type SpamAssassinResult struct {
IsSpam bool
Score float64
RequiredScore float64
Tests []string
TestDetails map[string]SpamTestDetail
Version string
RawReport string
}
// SpamTestDetail contains details about a specific spam test
type SpamTestDetail struct {
Name string
Score float64
Description string
}
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.SpamAssassinResult {
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAssassinResult {
headers := email.GetSpamAssassinHeaders()
if len(headers) == 0 {
return nil
}
// Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report
_, hasStatus := headers["X-Spam-Status"]
_, hasScore := headers["X-Spam-Score"]
_, hasFlag := headers["X-Spam-Flag"]
if !hasStatus && !hasScore && !hasFlag {
return nil
}
result := &model.SpamAssassinResult{
TestDetails: make(map[string]model.SpamTestDetail),
result := &SpamAssassinResult{
TestDetails: make(map[string]SpamTestDetail),
}
// Parse X-Spam-Status header
if statusHeader, ok := headers["X-Spam-Status"]; ok && statusHeader != "" {
if statusHeader, ok := headers["X-Spam-Status"]; ok {
a.parseSpamStatus(statusHeader, result)
}
// Parse X-Spam-Score header (as fallback if not in X-Spam-Status)
if scoreHeader, ok := headers["X-Spam-Score"]; ok && result.Score == 0 {
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
result.Score = float32(score)
result.Score = score
}
}
@ -77,13 +86,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.S
// Parse X-Spam-Report header for detailed test results
if reportHeader, ok := headers["X-Spam-Report"]; ok {
result.Report = utils.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
result.RawReport = strings.Replace(reportHeader, " * ", "\n * ", -1)
a.parseSpamReport(reportHeader, result)
}
// Parse X-Spam-Checker-Version
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
result.Version = utils.PtrTo(strings.TrimSpace(versionHeader))
result.Version = strings.TrimSpace(versionHeader)
}
return result
@ -91,7 +100,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.S
// parseSpamStatus parses the X-Spam-Status header
// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.SpamAssassinResult) {
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssassinResult) {
// Check if spam (first word)
parts := strings.SplitN(header, ",", 2)
if len(parts) > 0 {
@ -103,7 +112,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.Spam
scoreRe := regexp.MustCompile(`score=(-?\d+\.?\d*)`)
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 1 {
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
result.Score = float32(score)
result.Score = score
}
}
@ -111,19 +120,19 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.Spam
requiredRe := regexp.MustCompile(`required=(-?\d+\.?\d*)`)
if matches := requiredRe.FindStringSubmatch(header); len(matches) > 1 {
if required, err := strconv.ParseFloat(matches[1], 64); err == nil {
result.RequiredScore = float32(required)
result.RequiredScore = required
}
}
// Extract tests
testsRe := regexp.MustCompile(`tests=([^=]+)(?:\s|$)`)
testsRe := regexp.MustCompile(`tests=([^\s]+)`)
if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 {
testsStr := matches[1]
// Tests can be comma or space separated
tests := strings.FieldsFunc(testsStr, func(r rune) bool {
return r == ',' || r == ' '
})
result.Tests = &tests
result.Tests = tests
}
}
@ -131,20 +140,17 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.Spam
// Format varies, but typically:
// * 1.5 TEST_NAME Description of test
// * 0.0 TEST_NAME2 Description
// Multiline descriptions continue on lines starting with * but without score:
// * 0.0 TEST_NAME Description line 1
// * continuation line 2
// * continuation line 3
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.SpamAssassinResult) {
// Note: mail.Header.Get() joins continuation lines, so newlines are removed.
// We split on '*' to separate individual tests.
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
// The report header has been joined by mail.Header.Get(), so we split on '*'
// Each segment starting with '*' is either a test line or continuation
segments := strings.Split(report, "*")
// Regex to match test lines: score TEST_NAME Description
// Format: " 0.0 TEST_NAME Description" or " -0.1 TEST_NAME Description"
testRe := regexp.MustCompile(`^\s*(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
var currentTestName string
var currentDescription strings.Builder
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
@ -154,76 +160,186 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.Spam
// Try to match as a test line
matches := testRe.FindStringSubmatch(segment)
if len(matches) > 3 {
// Save previous test if exists
if currentTestName != "" {
description := strings.TrimSpace(currentDescription.String())
detail := model.SpamTestDetail{
Name: currentTestName,
Score: result.TestDetails[currentTestName].Score,
Description: &description,
}
result.TestDetails[currentTestName] = detail
}
// Start new test
testName := matches[2]
score, _ := strconv.ParseFloat(matches[1], 64)
description := strings.TrimSpace(matches[3])
currentTestName = testName
currentDescription.Reset()
currentDescription.WriteString(description)
// Initialize with score
result.TestDetails[testName] = model.SpamTestDetail{
Name: testName,
Score: float32(score),
detail := SpamTestDetail{
Name: testName,
Score: score,
Description: description,
}
} else if currentTestName != "" {
// This is a continuation line for the current test
// Add a space before appending to ensure proper word separation
if currentDescription.Len() > 0 {
currentDescription.WriteString(" ")
}
currentDescription.WriteString(segment)
result.TestDetails[testName] = detail
}
}
// Save the last test if exists
if currentTestName != "" {
description := strings.TrimSpace(currentDescription.String())
detail := model.SpamTestDetail{
Name: currentTestName,
Score: result.TestDetails[currentTestName].Score,
Description: &description,
}
result.TestDetails[currentTestName] = detail
}
}
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *model.SpamAssassinResult) (int, string) {
// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-2 points)
// Scoring:
// - Score <= 0: 2 points (excellent)
// - Score < required: 1.5 points (good)
// - Score slightly above required (< 2x): 1 point (borderline)
// - Score moderately high (< 3x required): 0.5 points (poor)
// - Score very high: 0 points (spam)
func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 {
if result == nil {
return 100, "" // No spam scan results, assume good
return 0.0
}
// SpamAssassin score typically ranges from -10 to +20
// Score < 0 is very likely ham (good)
// Score 0-5 is threshold range (configurable, usually 5.0)
// Score > 5 is likely spam
score := result.Score
// Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better)
if score < 0 {
return 100, "A+" // Perfect score for ham
} else if score == 0 {
return 100, "A" // Perfect score for ham
} else if score >= result.RequiredScore {
return 0, "F" // Failed spam test
} else {
// Linear scale between 0 and required threshold
percentage := 100 - int(math.Round(float64(score*100/(2*result.RequiredScore))))
return percentage, ScoreToGrade(percentage - 5)
required := result.RequiredScore
if required == 0 {
required = 5.0 // Default SpamAssassin threshold
}
// Calculate deliverability score
if score <= 0 {
return 2.0
} else if score < required {
// Linear scaling from 1.5 to 2.0 based on how negative/low the score is
ratio := score / required
return 1.5 + (0.5 * (1.0 - float32(ratio)))
} else if score < required*2 {
// Slightly above threshold
return 1.0
} else if score < required*3 {
// Moderately high
return 0.5
}
// Very high spam score
return 0.0
}
// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis
func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check {
var checks []api.Check
if result == nil {
checks = append(checks, api.Check{
Category: api.Spam,
Name: "SpamAssassin Analysis",
Status: api.CheckStatusWarn,
Score: 0.0,
Message: "No SpamAssassin headers found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
})
return checks
}
// Main spam score check
mainCheck := a.generateMainSpamCheck(result)
checks = append(checks, mainCheck)
// Add checks for significant spam tests (score > 1.0 or < -1.0)
for _, test := range result.Tests {
if detail, ok := result.TestDetails[test]; ok {
if detail.Score > 1.0 || detail.Score < -1.0 {
check := a.generateTestCheck(detail)
checks = append(checks, check)
}
}
}
return checks
}
// generateMainSpamCheck creates the main spam score check
func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check {
check := api.Check{
Category: api.Spam,
Name: "SpamAssassin Score",
}
score := result.Score
required := result.RequiredScore
if required == 0 {
required = 5.0
}
delivScore := a.GetSpamAssassinScore(result)
check.Score = delivScore
// Determine status and message based on score
if score <= 0 {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
} else if score < required {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your email passes spam filters")
} else if score < required*1.5 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
} else if score < required*2 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
} else {
check.Status = api.CheckStatusFail
check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
}
// Add details
if len(result.Tests) > 0 {
details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", "))
if len(result.Tests) > 5 {
details += fmt.Sprintf(" and %d more", len(result.Tests)-5)
}
check.Details = &details
}
return check
}
// generateTestCheck creates a check for a specific spam test
func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check {
check := api.Check{
Category: api.Spam,
Name: fmt.Sprintf("Spam Test: %s", detail.Name),
}
if detail.Score > 0 {
// Negative indicator (increases spam score)
if detail.Score > 2.0 {
check.Status = api.CheckStatusFail
check.Severity = api.PtrTo(api.CheckSeverityHigh)
} else {
check.Status = api.CheckStatusWarn
check.Severity = api.PtrTo(api.CheckSeverityMedium)
}
check.Score = 0.0
check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score)
check.Advice = &advice
} else {
// Positive indicator (decreases spam score)
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score)
advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score)
check.Advice = &advice
}
check.Details = &detail.Description
return check
}
// min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -27,8 +27,7 @@ import (
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseSpamStatus(t *testing.T) {
@ -36,8 +35,8 @@ func TestParseSpamStatus(t *testing.T) {
name string
header string
expectedIsSpam bool
expectedScore float32
expectedReq float32
expectedScore float64
expectedReq float64
expectedTests []string
}{
{
@ -78,8 +77,8 @@ func TestParseSpamStatus(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &model.SpamAssassinResult{
TestDetails: make(map[string]model.SpamTestDetail),
result := &SpamAssassinResult{
TestDetails: make(map[string]SpamTestDetail),
}
analyzer.parseSpamStatus(tt.header, result)
@ -92,12 +91,8 @@ func TestParseSpamStatus(t *testing.T) {
if result.RequiredScore != tt.expectedReq {
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, tt.expectedReq)
}
if len(tt.expectedTests) > 0 {
if result.Tests == nil {
t.Errorf("Tests = nil, want %v", tt.expectedTests)
} else if !stringSliceEqual(*result.Tests, tt.expectedTests) {
t.Errorf("Tests = %v, want %v", *result.Tests, tt.expectedTests)
}
if len(tt.expectedTests) > 0 && !stringSliceEqual(result.Tests, tt.expectedTests) {
t.Errorf("Tests = %v, want %v", result.Tests, tt.expectedTests)
}
})
}
@ -116,27 +111,27 @@ func TestParseSpamReport(t *testing.T) {
`
analyzer := NewSpamAssassinAnalyzer()
result := &model.SpamAssassinResult{
TestDetails: make(map[string]model.SpamTestDetail),
result := &SpamAssassinResult{
TestDetails: make(map[string]SpamTestDetail),
}
analyzer.parseSpamReport(report, result)
expectedTests := map[string]model.SpamTestDetail{
expectedTests := map[string]SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
Description: utils.PtrTo("Bayes spam probability is 99 to 100%"),
Description: "Bayes spam probability is 99 to 100%",
},
"SPOOFED_SENDER": {
Name: "SPOOFED_SENDER",
Score: 3.5,
Description: utils.PtrTo("From address doesn't match envelope sender"),
Description: "From address doesn't match envelope sender",
},
"ALL_TRUSTED": {
Name: "ALL_TRUSTED",
Score: -1.0,
Description: utils.PtrTo("All mail servers are trusted"),
Description: "All mail servers are trusted",
},
}
@ -149,8 +144,8 @@ func TestParseSpamReport(t *testing.T) {
if detail.Score != expected.Score {
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expected.Score)
}
if *detail.Description != *expected.Description {
t.Errorf("Test %s description = %q, want %q", testName, *detail.Description, *expected.Description)
if detail.Description != expected.Description {
t.Errorf("Test %s description = %q, want %q", testName, detail.Description, expected.Description)
}
}
}
@ -158,63 +153,56 @@ func TestParseSpamReport(t *testing.T) {
func TestGetSpamAssassinScore(t *testing.T) {
tests := []struct {
name string
result *model.SpamAssassinResult
expectedScore int
minScore int
maxScore int
result *SpamAssassinResult
expectedScore float32
minScore float32
maxScore float32
}{
{
name: "Nil result",
result: nil,
expectedScore: 100,
expectedScore: 0.0,
},
{
name: "Excellent score (negative)",
result: &model.SpamAssassinResult{
result: &SpamAssassinResult{
Score: -2.5,
RequiredScore: 5.0,
},
expectedScore: 100,
expectedScore: 2.0,
},
{
name: "Good score (below threshold)",
result: &model.SpamAssassinResult{
result: &SpamAssassinResult{
Score: 2.0,
RequiredScore: 5.0,
},
expectedScore: 80, // 100 - round(2*100/5) = 100 - 40 = 60
minScore: 1.5,
maxScore: 2.0,
},
{
name: "Score at threshold",
result: &model.SpamAssassinResult{
Score: 5.0,
RequiredScore: 5.0,
},
expectedScore: 0, // >= threshold = 0
},
{
name: "Above threshold (spam)",
result: &model.SpamAssassinResult{
name: "Borderline (just above threshold)",
result: &SpamAssassinResult{
Score: 6.0,
RequiredScore: 5.0,
},
expectedScore: 0, // >= threshold = 0
expectedScore: 1.0,
},
{
name: "High spam score",
result: &model.SpamAssassinResult{
result: &SpamAssassinResult{
Score: 12.0,
RequiredScore: 5.0,
},
expectedScore: 0, // >= threshold = 0
expectedScore: 0.5,
},
{
name: "Very high spam score",
result: &model.SpamAssassinResult{
result: &SpamAssassinResult{
Score: 20.0,
RequiredScore: 5.0,
},
expectedScore: 0, // >= threshold = 0
expectedScore: 0.0,
},
}
@ -222,7 +210,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score, _ := analyzer.CalculateSpamAssassinScore(tt.result)
score := analyzer.GetSpamAssassinScore(tt.result)
if tt.minScore > 0 || tt.maxScore > 0 {
if score < tt.minScore || score > tt.maxScore {
@ -242,7 +230,7 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
name string
headers map[string]string
expectedIsSpam bool
expectedScore float32
expectedScore float64
expectedHasDetails bool
}{
{
@ -308,6 +296,86 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
}
}
func TestGenerateSpamAssassinChecks(t *testing.T) {
tests := []struct {
name string
result *SpamAssassinResult
expectedStatus api.CheckStatus
minChecks int
}{
{
name: "Nil result",
result: nil,
expectedStatus: api.CheckStatusWarn,
minChecks: 1,
},
{
name: "Clean email",
result: &SpamAssassinResult{
IsSpam: false,
Score: -0.5,
RequiredScore: 5.0,
Tests: []string{"ALL_TRUSTED"},
TestDetails: map[string]SpamTestDetail{
"ALL_TRUSTED": {
Name: "ALL_TRUSTED",
Score: -1.5,
Description: "All mail servers are trusted",
},
},
},
expectedStatus: api.CheckStatusPass,
minChecks: 2, // Main check + one test detail
},
{
name: "Spam email",
result: &SpamAssassinResult{
IsSpam: true,
Score: 15.0,
RequiredScore: 5.0,
Tests: []string{"BAYES_99", "SPOOFED_SENDER"},
TestDetails: map[string]SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
Description: "Bayes spam probability is 99 to 100%",
},
"SPOOFED_SENDER": {
Name: "SPOOFED_SENDER",
Score: 3.5,
Description: "From address doesn't match envelope sender",
},
},
},
expectedStatus: api.CheckStatusFail,
minChecks: 3, // Main check + two significant tests
},
}
analyzer := NewSpamAssassinAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateSpamAssassinChecks(tt.result)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Check main check (first one)
if len(checks) > 0 {
mainCheck := checks[0]
if mainCheck.Status != tt.expectedStatus {
t.Errorf("Main check status = %v, want %v", mainCheck.Status, tt.expectedStatus)
}
if mainCheck.Category != api.Spam {
t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
}
}
})
}
}
func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
email := &EmailMessage{
@ -321,6 +389,98 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
}
}
func TestGenerateMainSpamCheck(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
tests := []struct {
name string
score float64
required float64
expectedStatus api.CheckStatus
}{
{"Excellent", -1.0, 5.0, api.CheckStatusPass},
{"Good", 2.0, 5.0, api.CheckStatusPass},
{"Borderline", 6.0, 5.0, api.CheckStatusWarn},
{"High", 8.0, 5.0, api.CheckStatusWarn},
{"Very High", 15.0, 5.0, api.CheckStatusFail},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &SpamAssassinResult{
Score: tt.score,
RequiredScore: tt.required,
}
check := analyzer.generateMainSpamCheck(result)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Spam {
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
}
if !strings.Contains(check.Message, "spam score") {
t.Error("Message should contain 'spam score'")
}
})
}
}
func TestGenerateTestCheck(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
tests := []struct {
name string
detail SpamTestDetail
expectedStatus api.CheckStatus
}{
{
name: "High penalty test",
detail: SpamTestDetail{
Name: "BAYES_99",
Score: 5.0,
Description: "Bayes spam probability is 99 to 100%",
},
expectedStatus: api.CheckStatusFail,
},
{
name: "Medium penalty test",
detail: SpamTestDetail{
Name: "HTML_MESSAGE",
Score: 1.5,
Description: "Contains HTML",
},
expectedStatus: api.CheckStatusWarn,
},
{
name: "Positive test",
detail: SpamTestDetail{
Name: "ALL_TRUSTED",
Score: -2.0,
Description: "All mail servers are trusted",
},
expectedStatus: api.CheckStatusPass,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateTestCheck(tt.detail)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Spam {
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
}
if !strings.Contains(check.Name, tt.detail.Name) {
t.Errorf("Check name should contain test name %s", tt.detail.Name)
}
})
}
}
const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec
X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED,
@ -382,26 +542,24 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
}
// Validate score (should be -0.1)
var expectedScore float32 = -0.1
expectedScore := -0.1
if result.Score != expectedScore {
t.Errorf("Score = %v, want %v", result.Score, expectedScore)
}
// Validate required score (should be 5.0)
var expectedRequired float32 = 5.0
expectedRequired := 5.0
if result.RequiredScore != expectedRequired {
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired)
}
// Validate version
if result.Version == nil {
t.Errorf("Version should contain 'SpamAssassin', got: nil")
} else if !strings.Contains(*result.Version, "SpamAssassin") {
t.Errorf("Version should contain 'SpamAssassin', got: %s", *result.Version)
if !strings.Contains(result.Version, "SpamAssassin") {
t.Errorf("Version should contain 'SpamAssassin', got: %s", result.Version)
}
// Validate that tests were extracted
if len(*result.Tests) == 0 {
if len(result.Tests) == 0 {
t.Error("Expected tests to be extracted, got none")
}
@ -414,7 +572,7 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
"SPF_HELO_NONE": true,
}
for _, testName := range *result.Tests {
for _, testName := range result.Tests {
if expectedTests[testName] {
t.Logf("Found expected test: %s", testName)
}
@ -428,11 +586,11 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
// Log what we actually got for debugging
t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails))
for name, detail := range result.TestDetails {
t.Logf(" %s: score=%v, description=%s", name, detail.Score, *detail.Description)
t.Logf(" %s: score=%v, description=%s", name, detail.Score, detail.Description)
}
// Define expected test details with their scores
expectedTestDetails := map[string]float32{
expectedTestDetails := map[string]float64{
"SPF_PASS": -0.0,
"SPF_HELO_NONE": 0.0,
"DKIM_VALID": -0.1,
@ -453,15 +611,43 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
if detail.Score != expectedScore {
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore)
}
if detail.Description == nil || *detail.Description == "" {
if detail.Description == "" {
t.Errorf("Test %s should have a description", testName)
}
}
// Test GetSpamAssassinScore
score, _ := analyzer.CalculateSpamAssassinScore(result)
if score != 100 {
t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score)
score := analyzer.GetSpamAssassinScore(result)
if score != 2.0 {
t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score)
}
// Test GenerateSpamAssassinChecks
checks := analyzer.GenerateSpamAssassinChecks(result)
if len(checks) < 1 {
t.Fatal("Expected at least 1 check, got none")
}
// Main check should be PASS with excellent score
mainCheck := checks[0]
if mainCheck.Status != api.CheckStatusPass {
t.Errorf("Main check status = %v, want %v", mainCheck.Status, api.CheckStatusPass)
}
if mainCheck.Category != api.Spam {
t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
}
if !strings.Contains(mainCheck.Message, "spam score") {
t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message)
}
if mainCheck.Score != 2.0 {
t.Errorf("Main check score = %v, want 2.0", mainCheck.Score)
}
// Log all checks for debugging
t.Logf("Generated %d checks:", len(checks))
for i, check := range checks {
t.Logf(" Check %d: %s - %s (score: %.1f, status: %s)",
i+1, check.Name, check.Message, check.Score, check.Status)
}
}

2652
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,24 +16,24 @@
"generate:api": "openapi-ts"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^10.0.0",
"@hey-api/openapi-ts": "0.86.10",
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@hey-api/openapi-ts": "0.85.2",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^24.0.0",
"eslint": "^10.0.0",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/node": "^22",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^17.0.0",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^8.0.0",
"vite": "^7.1.10",
"vitest": "^3.2.4"
},
"dependencies": {

View file

@ -23,10 +23,9 @@ package web
import (
"encoding/json"
"flag"
"fmt"
"io"
"io/fs"
"io/ioutil"
"log"
"net/http"
"net/url"
@ -42,38 +41,12 @@ import (
var (
indexTpl *template.Template
CustomBodyHTML = ""
CustomHeadHTML = ""
)
func init() {
flag.StringVar(&CustomHeadHTML, "custom-head-html", CustomHeadHTML, "Add custom HTML right before </head>")
flag.StringVar(&CustomBodyHTML, "custom-body-html", CustomBodyHTML, "Add custom HTML right before </body>")
}
func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
appConfig := map[string]interface{}{}
if cfg.ReportRetention > 0 {
appConfig["report_retention"] = cfg.ReportRetention
}
if cfg.SurveyURL.Host != "" {
appConfig["survey_url"] = cfg.SurveyURL.String()
}
if len(cfg.Analysis.RBLs) > 0 {
appConfig["rbls"] = cfg.Analysis.RBLs
}
if cfg.CustomLogoURL != "" {
appConfig["custom_logo_url"] = cfg.CustomLogoURL
}
if !cfg.DisableTestList {
appConfig["test_list_enabled"] = true
}
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
log.Println("Unable to generate JSON config to inject in web application")
} else {
@ -93,13 +66,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
router.GET("/_app/immutable/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/", serveOrReverse("/", cfg))
router.GET("/blacklist/", serveOrReverse("/", cfg))
router.GET("/blacklist/:ip", serveOrReverse("/", cfg))
router.GET("/domain/", serveOrReverse("/", cfg))
router.GET("/domain/:domain", serveOrReverse("/", cfg))
router.GET("/test/", serveOrReverse("/", cfg))
router.GET("/test/:testid", serveOrReverse("/", cfg))
router.GET("/history/", serveOrReverse("/", cfg))
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/img/*path", serveOrReverse("", cfg))
@ -119,7 +85,7 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if u, err := url.Parse(cfg.DevProxy); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else {
if forced_url != "" && forced_url != "/" {
if forced_url != "" {
u.Path = path.Join(u.Path, forced_url)
} else {
u.Path = path.Join(u.Path, c.Request.URL.Path)
@ -148,16 +114,14 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
}
}
v, _ := io.ReadAll(resp.Body)
v, _ := ioutil.ReadAll(resp.Body)
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
v2 := strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
"Body": CustomBodyHTML,
"Head": CustomHeadHTML,
"RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
"Head": CustomHeadHTML,
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
@ -175,18 +139,16 @@ func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if indexTpl == nil {
// Create template from file
f, _ := Assets.Open("index.html")
v, _ := io.ReadAll(f)
v, _ := ioutil.ReadAll(f)
v2 := strings.Replace(strings.Replace(string(v), "</head>", `{{ .Head }}<meta property="og:url" content="{{ .RootURL }}"></head>`, 1), "</body>", "{{ .Body }}</body>", 1)
v2 := strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
}
// Serve template
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
"Body": CustomBodyHTML,
"Head": CustomHeadHTML,
"RootURL": fmt.Sprintf("https://%s/", c.Request.Host),
"Head": CustomHeadHTML,
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}

View file

@ -1,9 +1,6 @@
:root {
--bs-primary: #1cb487;
--bs-primary-rgb: 28, 180, 135;
--bs-link-color-rgb: 28, 180, 135;
--bs-link-hover-color-rgb: 17, 112, 84;
--bs-tertiary-bg: #e7e8e8;
}
body {
@ -11,10 +8,6 @@ body {
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.bg-tertiary {
background-color: var(--bs-tertiary-bg);
}
/* Animations */
@keyframes fadeIn {
from {
@ -81,21 +74,14 @@ body {
/* Custom card styling */
.card {
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.card:not(.fade-in .card) {
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.fade-in .card:not(.card .card) {
border: none;
}
.card:hover:not(.fade-in .card) {
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}

View file

@ -3,38 +3,9 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="happyDeliver - Test Your Email Deliverability" />
<meta
property="og:description"
content="Get detailed insights into your email configuration, authentication, spam score, and more. Open-source, self-hosted, and privacy-focused."
/>
<meta property="og:image" content="/img/og.webp" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="happyDeliver - Test Your Email Deliverability" />
<meta
name="twitter:description"
content="Get detailed insights into your email configuration, authentication, spam score, and more. Open-source, self-hosted, and privacy-focused."
/>
<meta name="twitter:image" content="/img/og.webp" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<script>
// Apply theme before render to prevent flash
(function () {
const stored = localStorage.getItem("theme");
const theme =
stored ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-bs-theme", theme);
})();
</script>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -1,99 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
sodipodi:docname="favicon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2">
<marker
orient="auto"
refY="0.0"
refX="0.0"
id="Arrow1Lstart"
style="overflow:visible">
<path
id="path1110"
d="M 0.0,0.0 L 5.0,-5.0 L -12.5,0.0 L 5.0,5.0 L 0.0,0.0 z "
style="fill-rule:evenodd;stroke:#000000;stroke-width:1.0pt"
transform="scale(0.8) translate(12.5,0)" />
</marker>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
showgrid="false"
units="px"
showborder="true"
borderlayer="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
transform="translate(0,-229.26665)">
<path
style="fill:none;stroke:#d7b3e6;stroke-width:3.0463;stroke-dasharray:none;stroke-opacity:1"
d="M 4.744943,232.9437 62.17147,232.53602 35.338987,253.12317 Z"
id="path1" />
<rect
style="fill:#1cb487;fill-opacity:0;stroke:#c38dd9;stroke-width:6.17022181;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect819"
width="61.563114"
height="61.56311"
x="232.35176"
y="-64.648224"
ry="2.1808801"
rx="2.147301"
transform="rotate(90)" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:113.242px;line-height:125%;font-family:'Fortheenas_01 Bold';-inkscape-font-specification:'Fortheenas_01 Bold, ';letter-spacing:0px;word-spacing:0px;fill:#871cb4;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
x="-5.1976156"
y="308.21759"
id="text817"><tspan
sodipodi:role="line"
id="tspan815"
x="-5.1976156"
y="308.21759"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:107.597px;line-height:100%;font-family:'Fortheenas_01 Bold';-inkscape-font-specification:'Fortheenas_01 Bold, ';writing-mode:lr-tb;direction:ltr;fill:#871cb4;fill-opacity:1;stroke-width:0.264583px">h</tspan></text>
<path
style="fill:none;fill-rule:evenodd;stroke:#871cb4;stroke-width:12.70902443;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 5.0595093,228.81348 -0.02912,7.90683"
id="path1395"
sodipodi:nodetypes="cc" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1589"
width="119.36257"
height="43.404568"
x="-36.662537"
y="297.12582"
rx="0.94925886"
ry="0.91267759" />
<path
d="m 57.00432,267.54566 a 2.7691676,2.7691676 0 0 1 3.950621,3.8768 l -14.731868,18.42418 a 2.7691676,2.7691676 0 0 1 -3.987601,0.0697 l -9.769659,-9.76952 a 2.7691676,2.7691676 0 1 1 3.913781,-3.91379 l 7.731412,7.72779 12.823121,-16.33807 z"
id="path1-3"
style="fill:#67ccaf;fill-opacity:0;stroke-width:3.69222" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -1,567 +0,0 @@
<script lang="ts">
import type { AuthenticationResults, DnsResults } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import { theme } from "$lib/stores/theme";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props {
authentication: AuthenticationResults;
authenticationGrade?: string;
authenticationScore?: number;
dnsResults?: DnsResults;
}
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
let allRequiredMissing = $derived(
!authentication.spf &&
(!authentication.dkim || authentication.dkim.length === 0) &&
!authentication.dmarc,
);
function getAuthResultClass(result: string, noneIsFail: boolean): string {
switch (result) {
case "pass":
case "domain_pass":
case "orgdomain_pass":
return "text-success";
case "permerror":
case "error":
case "fail":
case "missing":
case "invalid":
case "null":
case "null_smtp":
case "null_header":
return "text-danger";
case "softfail":
case "neutral":
return "text-warning";
case "declined":
return "text-info";
case "none":
return noneIsFail ? "text-danger" : "text-muted";
default:
return "text-muted";
}
}
function getAuthResultIcon(result: string, noneIsFail: boolean): string {
switch (result) {
case "pass":
case "domain_pass":
case "orgdomain_pass":
return "bi-check-circle-fill";
case "fail":
return "bi-x-circle-fill";
case "softfail":
case "neutral":
case "invalid":
case "null":
case "permerror":
case "error":
case "null_smtp":
case "null_header":
return "bi-exclamation-circle-fill";
case "missing":
return "bi-dash-circle-fill";
case "declined":
return "bi-dash-circle";
case "none":
return noneIsFail ? "bi-x-circle-fill" : "bi-question-circle";
default:
return "bi-question-circle";
}
}
function getAuthResultText(result: string): string {
switch (result) {
case "missing":
return "Not configured";
default:
return result;
}
}
</script>
<div class="card shadow-sm" id="authentication-details">
<div class="card-header {$theme === 'light' ? 'bg-white' : 'bg-dark'}">
<h4 class="mb-0 d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-shield-check me-2"></i>
Authentication
</span>
<span>
{#if authenticationScore !== undefined}
<span class="badge bg-{getScoreColorClass(authenticationScore)}">
{authenticationScore}%
</span>
{/if}
{#if authenticationGrade !== undefined}
<GradeDisplay grade={authenticationGrade} size="small" />
{/if}
</span>
</h4>
</div>
{#if allRequiredMissing}
<div class="card-body border-bottom">
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>No authentication results found.</strong>
<p class="mb-0 mt-1">
This usually means either:
</p>
<ul class="mb-0 mt-1">
<li>
The receiving mail server is not configured to verify email authentication
(no <code>Authentication-Results</code> header was found in the message).
</li>
<li>
The <code>Authentication-Results</code> header exists but the receiver
hostname does not match the configured
<code>--receiver-hostname</code> value.
</li>
</ul>
</div>
</div>
{/if}
<div class="list-group list-group-flush">
<!-- IPREV -->
{#if authentication.iprev}
<div class="list-group-item" id="authentication-iprev">
<div class="d-flex align-items-start">
<i
class="bi {getAuthResultIcon(
authentication.iprev.result,
true,
)} {getAuthResultClass(authentication.iprev.result, true)} me-2 fs-5"
></i>
<div>
<strong>IP Reverse DNS</strong>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.iprev.result,
true,
)}"
>
{authentication.iprev.result}
</span>
{#if authentication.iprev.ip}
<div class="small">
<strong>IP Address:</strong>
<span class="text-muted">{authentication.iprev.ip}</span>
</div>
{/if}
{#if authentication.iprev.hostname}
<div class="small">
<strong>Hostname:</strong>
<span class="text-muted">{authentication.iprev.hostname}</span>
</div>
{/if}
{#if authentication.iprev.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.iprev.details}</pre>
{/if}
</div>
</div>
</div>
{/if}
<!-- SPF (Required) -->
<div class="list-group-item">
<div class="d-flex align-items-start" id="authentication-spf">
{#if authentication.spf}
<i
class="bi {getAuthResultIcon(
authentication.spf.result,
true,
)} {getAuthResultClass(authentication.spf.result, true)} me-2 fs-5"
></i>
<div>
<strong>SPF</strong>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.spf.result,
true,
)}"
>
{authentication.spf.result}
</span>
{#if authentication.spf.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{authentication.spf.domain}</span>
</div>
{/if}
{#if authentication.spf.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.spf.details}</pre>
{/if}
</div>
{:else}
<i
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
'missing',
true,
)} me-2 fs-5"
></i>
<div>
<strong>SPF</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
{getAuthResultText("missing")}
</span>
<div class="text-muted small">
SPF record is required for proper email authentication
</div>
</div>
{/if}
</div>
</div>
<!-- DKIM (Required) -->
<div class="list-group-item" id="authentication-dkim">
{#if authentication.dkim && authentication.dkim.length > 0}
{#each authentication.dkim as dkim, i}
<div class="d-flex align-items-start" class:mt-3={i > 0}>
<i
class="bi {getAuthResultIcon(dkim.result, true)} {getAuthResultClass(
dkim.result,
true,
)} me-2 fs-5"
></i>
<div>
<strong>DKIM{authentication.dkim.length > 1 ? ` #${i + 1}` : ""}</strong
>
<span
class="text-uppercase ms-2 {getAuthResultClass(dkim.result, true)}"
>
{dkim.result}
</span>
{#if dkim.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{dkim.domain}</span>
</div>
{/if}
{#if dkim.selector}
<div class="small">
<strong>Selector:</strong>
<span class="text-muted">{dkim.selector}</span>
</div>
{/if}
{#if dkim.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{dkim.details}</pre>
{/if}
</div>
</div>
{/each}
{:else}
<div class="d-flex align-items-start">
<i
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
'missing',
true,
)} me-2 fs-5"
></i>
<div>
<strong>DKIM</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
{getAuthResultText("missing")}
</span>
<div class="text-muted small">
DKIM signature is required for proper email authentication
</div>
</div>
</div>
{/if}
</div>
<!-- X-Google-DKIM (Optional) -->
{#if authentication.x_google_dkim}
<div class="list-group-item" id="authentication-x-google-dkim">
<div class="d-flex align-items-start">
<i
class="bi {getAuthResultIcon(
authentication.x_google_dkim.result,
false,
)} {getAuthResultClass(
authentication.x_google_dkim.result,
false,
)} me-2 fs-5"
></i>
<div>
<strong>X-Google-DKIM</strong>
<i
class="bi bi-info-circle text-muted ms-1"
title="Google's internal DKIM signature for messages routed through Gmail infrastructure"
></i>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.x_google_dkim.result,
false,
)}"
>
{authentication.x_google_dkim.result}
</span>
{#if authentication.x_google_dkim.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{authentication.x_google_dkim.domain}</span
>
</div>
{/if}
{#if authentication.x_google_dkim.selector}
<div class="small">
<strong>Selector:</strong>
<span class="text-muted"
>{authentication.x_google_dkim.selector}</span
>
</div>
{/if}
{#if authentication.x_google_dkim.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.x_google_dkim
.details}</pre>
{/if}
</div>
</div>
</div>
{/if}
<!-- X-Aligned-From (Disabled) -->
{#if authentication.x_aligned_from}
<div class="list-group-item" id="authentication-x-aligned-from">
<div class="d-flex align-items-start">
<i
class="bi {getAuthResultIcon(
authentication.x_aligned_from.result,
false,
)} {getAuthResultClass(
authentication.x_aligned_from.result,
false,
)} me-2 fs-5"
></i>
<div>
<strong>X-Aligned-From</strong>
<i
class="bi bi-info-circle text-muted ms-1"
title="Check that Mail From and Header From addresses are in alignment. See Domain Alignment section."
></i>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.x_aligned_from.result,
false,
)}"
>
{authentication.x_aligned_from.result}
</span>
{#if authentication.x_aligned_from.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted"
>{authentication.x_aligned_from.domain}</span
>
</div>
{/if}
{#if authentication.x_aligned_from.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.x_aligned_from
.details}</pre>
{/if}
</div>
</div>
</div>
{/if}
<!-- DMARC (Required) -->
<div class="list-group-item" id="authentication-dmarc">
<div class="d-flex align-items-start">
{#if authentication.dmarc}
<i
class="bi {getAuthResultIcon(
authentication.dmarc.result,
true,
)} {getAuthResultClass(authentication.dmarc.result, true)} me-2 fs-5"
></i>
<div>
<strong>DMARC</strong>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.dmarc.result,
true,
)}"
>
{authentication.dmarc.result}
</span>
{#if authentication.dmarc.domain}
<div class="small">
<strong>Domain:</strong>
<span class="text-muted">{authentication.dmarc.domain}</span>
</div>
{/if}
{#snippet DMARCPolicy(policy: string)}
<div class="small">
<strong>Policy:</strong>
<span
class="fw-bold"
class:text-success={policy == "reject"}
class:text-warning={policy == "quarantine"}
class:text-danger={policy == "none"}
class:bg-warning={policy != "none" &&
policy != "quarantine" &&
policy != "reject"}
>
{policy}
</span>
</div>
{/snippet}
{#if authentication.dmarc.result != "none"}
{#if authentication.dmarc.details && authentication.dmarc.details.indexOf("policy.published-domain-policy=") > 0}
{@const policy = authentication.dmarc.details.replace(
/^.*policy.published-domain-policy=([^\s]+).*$/,
"$1",
)}
{@render DMARCPolicy(policy)}
{:else if authentication.dmarc.domain && dnsResults?.dmarc_record?.policy}
{@render DMARCPolicy(dnsResults.dmarc_record.policy)}
{/if}
{/if}
{#if authentication.dmarc.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.dmarc.details}</pre>
{/if}
</div>
{:else}
<i
class="bi {getAuthResultIcon('missing', true)} {getAuthResultClass(
'missing',
true,
)} me-2 fs-5"
></i>
<div>
<strong>DMARC</strong>
<span class="text-uppercase ms-2 {getAuthResultClass('missing', true)}">
{getAuthResultText("missing")}
</span>
<div class="text-muted small">
DMARC policy is required for proper email authentication
</div>
</div>
{/if}
</div>
</div>
<!-- BIMI (Optional) -->
<div class="list-group-item" id="authentication-bimi">
<div class="d-flex align-items-start">
{#if authentication.bimi && authentication.bimi.result != "none"}
<i
class="bi {getAuthResultIcon(
authentication.bimi.result,
false,
)} {getAuthResultClass(authentication.bimi.result, false)} me-2 fs-5"
></i>
<div>
<strong>BIMI</strong>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.bimi.result,
false,
)}"
>
{authentication.bimi.result}
</span>
{#if authentication.bimi.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.bimi.details}</pre>
{/if}
</div>
{:else if authentication.bimi && authentication.bimi.result == "none"}
<i class="bi bi-exclamation-circle-fill text-warning me-2 fs-5"></i>
<div>
<strong>BIMI</strong>
<span class="text-uppercase ms-2 text-warning"> NONE </span>
<div class="text-muted small">
Brand Indicators for Message Identification
</div>
{#if authentication.bimi.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.bimi.details}</pre>
{/if}
</div>
{:else}
<i class="bi bi-info-circle text-muted me-2 fs-5"></i>
<div>
<strong>BIMI</strong>
<span class="text-uppercase ms-2 text-muted"> Optional </span>
<div class="text-muted small">
Brand Indicators for Message Identification (optional enhancement)
</div>
</div>
{/if}
</div>
</div>
<!-- ARC (Optional) -->
{#if authentication.arc}
<div class="list-group-item" id="authentication-arc">
<div class="d-flex align-items-start">
<i
class="bi {getAuthResultIcon(
authentication.arc.result,
false,
)} {getAuthResultClass(authentication.arc.result, false)} me-2 fs-5"
></i>
<div>
<strong>ARC</strong>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.arc.result,
false,
)}"
>
{authentication.arc.result}
</span>
{#if authentication.arc.chain_length}
<div class="text-muted small">
Chain length: {authentication.arc.chain_length}
</div>
{/if}
{#if authentication.arc.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.arc.details}</pre>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>

Some files were not shown because too many files have changed in this diff Show more