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
132 changed files with 7631 additions and 16987 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

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,20 +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
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"]

120
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,7 +87,7 @@ 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.
Choose one of the following way to integrate happyDeliver in your existing setup:
@ -184,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`:
@ -220,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"
}
@ -264,14 +188,22 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
## 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

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,13 +109,12 @@ Default configuration for the Docker environment:
The container accepts these environment variables:
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
Note that the hostname of the container is used to filter the authentication tests results.
- `DOMAIN`: Email domain for test addresses (default: happydeliver.local)
- `HOSTNAME`: Container hostname (default: mail.happydeliver.local)
Example:
```bash
docker run -e HAPPYDELIVER_DOMAIN=example.com --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

60
go.mod
View file

@ -3,40 +3,36 @@ module git.happydns.org/happyDeliver
go 1.24.6
require (
github.com/JGLTechnologies/gin-rate-limit v1.5.6
github.com/emersion/go-smtp v0.24.0
github.com/getkin/kin-openapi v0.133.0
github.com/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.1.2
golang.org/x/net v0.50.0
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,35 +40,33 @@ 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.5.1 // 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.17.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.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
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
)

130
go.sum
View file

@ -1,21 +1,11 @@
github.com/JGLTechnologies/gin-rate-limit v1.5.6 h1:BrL2wXrF7SSqmB88YTGFVKMGVcjURMUeKqwQrlmzweI=
github.com/JGLTechnologies/gin-rate-limit v1.5.6/go.mod h1:fwUuBegxLKm8+/4ST0zDFssRFTFaVZ7bH3ApK7iNZww=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/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=
@ -24,8 +14,6 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
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/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=
@ -36,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.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
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.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
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/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=
@ -90,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=
@ -114,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=
@ -130,8 +116,8 @@ 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.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
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=
@ -156,12 +142,10 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX
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/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.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
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=
@ -174,38 +158,34 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKk
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=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
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=
@ -213,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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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=
@ -235,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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
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=
@ -262,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=
@ -285,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

@ -32,32 +32,20 @@ import (
"git.happydns.org/happyDeliver/internal/config"
"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 *DNSResults, score int, grade string)
CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []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(),
}
}
@ -68,41 +56,27 @@ 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, TestResponse{
Id: base32ID,
Id: testID,
Email: openapi_types.Email(email),
Status: TestResponseStatusPending,
Message: stringPtr("Send your test email to the given address"),
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, Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(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, Error{
Error: "internal_error",
@ -120,35 +94,29 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
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,
)
// 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,
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, Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(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, Error{
@ -171,19 +139,8 @@ 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, Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(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, Error{
@ -203,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, Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(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, Error{
Error: "not_found",
Message: "Email not found",
})
return
}
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve email",
Details: stringPtr(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, Error{
Error: "analysis_error",
Message: "Failed to re-analyze email",
Details: stringPtr(err.Error()),
})
return
}
// Update the report in storage
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to update report",
Details: stringPtr(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) {
@ -281,7 +181,7 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
mtaStatus := StatusComponentsMtaUp
c.JSON(http.StatusOK, Status{
Status: overallStatus,
Version: version.Version,
Version: "0.1.0-dev",
Components: &struct {
Database *StatusComponentsDatabase `json:"database,omitempty"`
Mta *StatusComponentsMta `json:"mta,omitempty"`
@ -292,92 +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 DomainTestRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: stringPtr(err.Error()),
})
return
}
// Perform domain analysis
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
// Convert grade string to DomainTestResponseGrade enum
var responseGrade DomainTestResponseGrade
switch grade {
case "A+":
responseGrade = DomainTestResponseGradeA
case "A":
responseGrade = DomainTestResponseGradeA1
case "B":
responseGrade = DomainTestResponseGradeB
case "C":
responseGrade = DomainTestResponseGradeC
case "D":
responseGrade = DomainTestResponseGradeD
case "E":
responseGrade = DomainTestResponseGradeE
case "F":
responseGrade = DomainTestResponseGradeF
default:
responseGrade = DomainTestResponseGradeF
}
// Build response
response := 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 BlacklistCheckRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: stringPtr(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, Error{
Error: "invalid_ip",
Message: "Invalid IP address",
Details: stringPtr(err.Error()),
})
return
}
// Build response
response := BlacklistCheckResponse{
Ip: request.Ip,
Blacklists: checks,
Whitelists: &whitelists,
ListedCount: listedCount,
Score: score,
Grade: BlacklistCheckResponseGrade(grade),
}
c.JSON(http.StatusOK, response)
}

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

@ -37,11 +37,7 @@ func declareFlags(o *Config) {
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.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")
// 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"
@ -42,9 +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
}
// DatabaseConfig contains database connection settings
@ -62,11 +58,9 @@ type EmailConfig struct {
// 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
DNSTimeout time.Duration
HTTPTimeout time.Duration
RBLs []string
}
// DefaultConfig returns a configuration with sensible defaults
@ -75,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",
@ -89,8 +82,6 @@ func DefaultConfig() *Config {
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,7 +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)
log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score)
// Marshal report to JSON
reportJSON, err := json.Marshal(result.Report)
@ -113,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, "<>")
@ -160,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

@ -43,7 +43,6 @@ 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)
// Close closes the database connection
@ -108,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
}
@ -118,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{})
@ -147,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,7 +23,6 @@ package analyzer
import (
"bytes"
"encoding/json"
"fmt"
"github.com/google/uuid"
@ -44,8 +43,6 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
cfg.Analysis.DNSTimeout,
cfg.Analysis.HTTPTimeout,
cfg.Analysis.RBLs,
cfg.Analysis.DNSWLs,
cfg.Analysis.CheckAllIPs,
)
return &EmailAnalyzer{
@ -81,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) (*api.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) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) {
// Check the IP against all configured RBLs
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
if err != nil {
return nil, nil, 0, 0, "", err
}
// Calculate score using the existing function
// Create a minimal RBLResults structure for scoring
results := &DNSListResults{
Checks: map[string][]api.BlacklistCheck{ip: checks},
IPsChecked: []string{ip},
ListedCount: listedCount,
}
score, grade := a.analyzer.generator.rblChecker.CalculateScore(results)
// Check the IP against all configured DNSWLs (informational only)
whitelists, _, err := a.analyzer.generator.dnswlChecker.CheckIP(ip)
if err != nil {
whitelists = nil
}
return checks, whitelists, listedCount, score, grade, nil
// 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,6 +22,8 @@
package analyzer
import (
"fmt"
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
@ -50,6 +52,13 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
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)
@ -117,64 +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 *api.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
// IPRev (15 points)
score += 15 * a.calculateIPRevScore(results) / 100
// SPF (25 points)
score += 25 * a.calculateSPFScore(results) / 100
// DKIM (23 points)
score += 23 * a.calculateDKIMScore(results) / 100
// X-Google-DKIM (optional) - penalty if failed
score += 12 * a.calculateXGoogleDKIMScore(results) / 100
// X-Aligned-From
score += 2 * a.calculateXAlignedFromScore(results) / 100
// DMARC (25 points)
score += 25 * a.calculateDMARCScore(results) / 100
// BIMI (10 points)
score += 10 * a.calculateBIMIScore(results) / 100
// Ensure score doesn't exceed 100
if score > 100 {
score = 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
}
}
return score, ScoreToGrade(score)
// 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
}
// 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,183 +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/api"
)
// 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) *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)
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "arc="))
return result
}
// parseARCHeaders parses ARC headers from email message
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *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 !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/api"
)
func TestParseARCResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.ARCResultResult
}{
{
name: "ARC pass",
part: "arc=pass",
expectedResult: api.ARCResultResultPass,
},
{
name: "ARC fail",
part: "arc=fail",
expectedResult: api.ARCResultResultFail,
},
{
name: "ARC none",
part: "arc=none",
expectedResult: api.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,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 analyzer
import (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// parseBIMIResult parses BIMI result from Authentication-Results
// Example: bimi=pass header.d=example.com header.selector=default
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *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
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi="))
return result
}
func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) {
if results.Bimi != nil {
switch results.Bimi.Result {
case api.AuthResultResultPass:
return 100
case api.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/api"
)
func TestParseBIMIResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "BIMI pass with domain and selector",
part: "bimi=pass header.d=example.com header.selector=default",
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "BIMI fail",
part: "bimi=fail header.d=example.com header.selector=default",
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "BIMI with short form (d= and selector=)",
part: "bimi=pass d=example.com selector=v1",
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "v1",
},
{
name: "BIMI none",
part: "bimi=none header.d=example.com",
expectedResult: api.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,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 (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// parseDKIMResult parses DKIM result from Authentication-Results
// Example: dkim=pass header.d=example.com header.s=selector1
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *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
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim="))
return result
}
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) {
// Expect at least one passing signature
if results.Dkim != nil && len(*results.Dkim) > 0 {
hasPass := false
hasNonPass := false
for _, dkim := range *results.Dkim {
if dkim.Result == api.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/api"
)
func TestParseDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "DKIM pass with domain and selector",
part: "dkim=pass header.d=example.com header.s=default",
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
expectedSelector: "default",
},
{
name: "DKIM fail",
part: "dkim=fail header.d=example.com header.s=selector1",
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
expectedSelector: "selector1",
},
{
name: "DKIM with short form (d= and s=)",
part: "dkim=pass d=example.com s=default",
expectedResult: api.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,68 +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/api"
)
// 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
}
result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc="))
return result
}
func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) {
if results.Dmarc != nil {
switch results.Dmarc.Result {
case api.AuthResultResultPass:
return 100
case api.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/api"
)
func TestParseDMARCResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedDomain string
}{
{
name: "DMARC pass",
part: "dmarc=pass action=none header.from=example.com",
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "DMARC fail",
part: "dmarc=fail action=quarantine header.from=example.com",
expectedResult: api.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,73 +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/api"
)
// parseIPRevResult parses IP reverse lookup result from Authentication-Results
// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)
func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult {
result := &api.IPRevResult{}
// Extract result (pass, fail, temperror, permerror, none)
re := regexp.MustCompile(`iprev=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.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 = api.PtrTo(strings.TrimPrefix(part, "iprev="))
return result
}
func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) {
if results.Iprev != nil {
switch results.Iprev.Result {
case api.Pass:
return 100
default: // fail, temperror, permerror
return 0
}
}
return 0
}

View file

@ -1,225 +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/api"
)
func TestParseIPRevResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.IPRevResultResult
expectedIP *string
expectedHostname *string
}{
{
name: "IPRev pass with IP and hostname",
part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
expectedResult: api.Pass,
expectedIP: api.PtrTo("195.110.101.58"),
expectedHostname: api.PtrTo("authsmtp74.register.it"),
},
{
name: "IPRev pass without smtp prefix",
part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
expectedResult: api.Pass,
expectedIP: api.PtrTo("192.0.2.1"),
expectedHostname: api.PtrTo("mail.example.com"),
},
{
name: "IPRev fail",
part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
expectedResult: api.Fail,
expectedIP: api.PtrTo("198.51.100.42"),
expectedHostname: api.PtrTo("unknown.host.com"),
},
{
name: "IPRev temperror",
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
expectedResult: api.Temperror,
expectedIP: api.PtrTo("203.0.113.1"),
expectedHostname: nil,
},
{
name: "IPRev permerror",
part: "iprev=permerror smtp.remote-ip=192.0.2.100",
expectedResult: api.Permerror,
expectedIP: api.PtrTo("192.0.2.100"),
expectedHostname: nil,
},
{
name: "IPRev with IPv6",
part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
expectedResult: api.Pass,
expectedIP: api.PtrTo("2001:db8::1"),
expectedHostname: api.PtrTo("ipv6.example.com"),
},
{
name: "IPRev with subdomain hostname",
part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
expectedResult: api.Pass,
expectedIP: api.PtrTo("192.0.2.50"),
expectedHostname: api.PtrTo("mail.subdomain.example.com"),
},
{
name: "IPRev pass without parentheses",
part: "iprev=pass smtp.remote-ip=192.0.2.200",
expectedResult: api.Pass,
expectedIP: api.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 *api.IPRevResultResult
expectedIP *string
expectedHostname *string
}{
{
name: "IPRev pass in Authentication-Results",
header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
expectedIPRevResult: api.PtrTo(api.Pass),
expectedIP: api.PtrTo("195.110.101.58"),
expectedHostname: api.PtrTo("authsmtp74.register.it"),
},
{
name: "IPRev with other authentication methods",
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com",
expectedIPRevResult: api.PtrTo(api.Pass),
expectedIP: api.PtrTo("192.0.2.1"),
expectedHostname: api.PtrTo("mail.example.com"),
},
{
name: "IPRev fail",
header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
expectedIPRevResult: api.PtrTo(api.Fail),
expectedIP: api.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: api.PtrTo(api.Pass),
expectedIP: api.PtrTo("192.0.2.1"),
expectedHostname: api.PtrTo("first.com"),
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := &api.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,105 +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/api"
)
// 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)
}
// 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 = api.PtrTo(strings.TrimPrefix(part, "spf="))
return result
}
// 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)
}
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 *api.AuthenticationResults) (score int) {
if results.Spf != nil {
switch results.Spf.Result {
case api.AuthResultResultPass:
return 100
case api.AuthResultResultNeutral, api.AuthResultResultNone:
return 50
case api.AuthResultResultSoftfail:
return 17
default: // fail, temperror, permerror
return 0
}
}
return 0
}

View file

@ -1,212 +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/api"
)
func TestParseSPFResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedDomain string
}{
{
name: "SPF pass with domain",
part: "spf=pass smtp.mailfrom=sender@example.com",
expectedResult: api.AuthResultResultPass,
expectedDomain: "example.com",
},
{
name: "SPF fail",
part: "spf=fail smtp.mailfrom=sender@example.com",
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "SPF neutral",
part: "spf=neutral smtp.mailfrom=sender@example.com",
expectedResult: api.AuthResultResultNeutral,
expectedDomain: "example.com",
},
{
name: "SPF softfail",
part: "spf=softfail smtp.mailfrom=sender@example.com",
expectedResult: api.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 api.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: api.AuthResultResultPass,
expectedDomain: api.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: api.AuthResultResultFail,
expectedDomain: api.PtrTo("test.com"),
},
{
name: "SPF softfail",
receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"",
expectedResult: api.AuthResultResultSoftfail,
expectedDomain: api.PtrTo("example.org"),
},
{
name: "SPF neutral",
receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"",
expectedResult: api.AuthResultResultNeutral,
expectedDomain: api.PtrTo("domain.net"),
},
{
name: "SPF none",
receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"",
expectedResult: api.AuthResultResultNone,
expectedDomain: api.PtrTo("company.io"),
},
{
name: "SPF temperror",
receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"",
expectedResult: api.AuthResultResultTemperror,
expectedDomain: api.PtrTo("shop.example"),
},
{
name: "SPF permerror",
receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"",
expectedResult: api.AuthResultResultPermerror,
expectedDomain: api.PtrTo("invalid.test"),
},
{
name: "SPF pass without domain extraction",
receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
expectedResult: api.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: api.AuthResultResultPass,
expectedDomain: api.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,65 +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/api"
)
// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
// Example: x-aligned-from=pass (Address match)
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`x-aligned-from=([\w]+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
}
// Extract details (everything after the result)
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
return result
}
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) {
if results.XAlignedFrom != nil {
switch results.XAlignedFrom.Result {
case api.AuthResultResultPass:
// pass: positive contribution
return 100
case api.AuthResultResultFail:
// fail: negative contribution
return 0
default:
// neutral, none, etc.: no impact
return 0
}
}
return 0
}

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/api"
)
func TestParseXAlignedFromResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedDetail string
}{
{
name: "x-aligned-from pass with details",
part: "x-aligned-from=pass (Address match)",
expectedResult: api.AuthResultResultPass,
expectedDetail: "pass (Address match)",
},
{
name: "x-aligned-from fail with reason",
part: "x-aligned-from=fail (Address mismatch)",
expectedResult: api.AuthResultResultFail,
expectedDetail: "fail (Address mismatch)",
},
{
name: "x-aligned-from pass minimal",
part: "x-aligned-from=pass",
expectedResult: api.AuthResultResultPass,
expectedDetail: "pass",
},
{
name: "x-aligned-from neutral",
part: "x-aligned-from=neutral (No alignment check performed)",
expectedResult: api.AuthResultResultNeutral,
expectedDetail: "neutral (No alignment check performed)",
},
{
name: "x-aligned-from none",
part: "x-aligned-from=none",
expectedResult: api.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 *api.AuthResult
expectedScore int
}{
{
name: "pass result gives positive score",
result: &api.AuthResult{
Result: api.AuthResultResultPass,
},
expectedScore: 100,
},
{
name: "fail result gives zero score",
result: &api.AuthResult{
Result: api.AuthResultResultFail,
},
expectedScore: 0,
},
{
name: "neutral result gives zero score",
result: &api.AuthResult{
Result: api.AuthResultResultNeutral,
},
expectedScore: 0,
},
{
name: "none result gives zero score",
result: &api.AuthResult{
Result: api.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 := &api.AuthenticationResults{
XAlignedFrom: tt.result,
}
score := analyzer.calculateXAlignedFromScore(results)
if score != tt.expectedScore {
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
}
})
}
}

View file

@ -1,73 +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/api"
)
// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results
// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6
func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`x-google-dkim=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = 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) - 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 = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
return result
}
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) {
if results.XGoogleDkim != nil {
switch results.XGoogleDkim.Result {
case api.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/api"
)
func TestParseXGoogleDKIMResult(t *testing.T) {
tests := []struct {
name string
part string
expectedResult api.AuthResultResult
expectedDomain string
expectedSelector string
}{
{
name: "x-google-dkim pass with domain",
part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6",
expectedResult: api.AuthResultResultPass,
expectedDomain: "1e100.net",
},
{
name: "x-google-dkim pass with short form",
part: "x-google-dkim=pass d=gmail.com",
expectedResult: api.AuthResultResultPass,
expectedDomain: "gmail.com",
},
{
name: "x-google-dkim fail",
part: "x-google-dkim=fail header.d=example.com",
expectedResult: api.AuthResultResultFail,
expectedDomain: "example.com",
},
{
name: "x-google-dkim with minimal info",
part: "x-google-dkim=pass",
expectedResult: api.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,6 +22,11 @@
package analyzer
import (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
"git.happydns.org/happyDeliver/internal/api"
@ -30,212 +35,685 @@ import (
// 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, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.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 &api.DNSResults{
Errors: &[]string{"Unable to extract domain from email"},
}
}
fromDomain := *headersResults.DomainAlignment.FromDomain
results := &api.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)
results := &DNSResults{
Domain: domain,
}
// 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 MX records
results.MXRecords = d.checkMXRecords(domain)
// Check SPF record
results.SPFRecord = d.checkSPFRecord(domain)
// Check DKIM records (from authentication results)
// DKIM can be for any domain, but typically the From domain
if authResults != nil && authResults.Dkim != nil {
for _, dkim := range *authResults.Dkim {
if dkim.Domain != nil && dkim.Selector != nil {
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
if dkimRecord != nil {
if results.DkimRecords == nil {
results.DkimRecords = new([]api.DKIMRecord)
}
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
results.DKIMRecords = append(results.DKIMRecords, *dkimRecord)
}
}
}
}
// 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) *api.DNSResults {
results := &api.DNSResults{
FromDomain: domain,
}
// Check MX records
results.FromMxRecords = d.checkMXRecords(domain)
// Check SPF records
results.SpfRecords = d.checkSPFRecords(domain)
// 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 *api.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 *api.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,114 +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/api"
)
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.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 &api.BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
}
}
if len(txtRecords) == 0 {
return &api.BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: api.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 &api.BIMIRecord{
Selector: selector,
Domain: domain,
Record: &bimiRecord,
LogoUrl: &logoURL,
VmcUrl: &vmcURL,
Valid: false,
Error: api.PtrTo("BIMI record appears malformed"),
}
}
return &api.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,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"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
// DKIM records are at: selector._domainkey.domain
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
if err != nil {
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
}
}
if len(txtRecords) == 0 {
return &api.DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: api.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 &api.DKIMRecord{
Selector: selector,
Domain: domain,
Record: api.PtrTo(dkimRecord),
Valid: false,
Error: api.PtrTo("DKIM record appears malformed"),
}
}
return &api.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 *api.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,72 +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 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,256 +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/api"
)
// checkapi.DMARCRecord looks up and validates DMARC record for a domain
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord {
// DMARC records are at: _dmarc.domain
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
if err != nil {
return &api.DMARCRecord{
Valid: false,
Error: api.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 &api.DMARCRecord{
Valid: false,
Error: api.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 &api.DMARCRecord{
Record: &dmarcRecord,
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
SubdomainPolicy: subdomainPolicy,
Percentage: percentage,
SpfAlignment: spfAlignment,
DkimAlignment: dkimAlignment,
Valid: false,
Error: api.PtrTo("DMARC record appears malformed"),
}
}
return &api.DMARCRecord{
Record: &dmarcRecord,
Policy: api.PtrTo(api.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) *api.DMARCRecordSpfAlignment {
// Look for aspf=s (strict) or aspf=r (relaxed)
re := regexp.MustCompile(`aspf=(r|s)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
if matches[1] == "s" {
return api.PtrTo(api.DMARCRecordSpfAlignmentStrict)
}
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
}
// Default is relaxed if not specified
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
}
// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record
// Returns "relaxed" (default) or "strict"
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment {
// Look for adkim=s (strict) or adkim=r (relaxed)
re := regexp.MustCompile(`adkim=(r|s)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
if matches[1] == "s" {
return api.PtrTo(api.DMARCRecordDkimAlignmentStrict)
}
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
}
// Default is relaxed if not specified
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
}
// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record
// Returns the sp tag value or nil if not specified (defaults to main policy)
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy {
// Look for sp=none, sp=quarantine, or sp=reject
re := regexp.MustCompile(`sp=(none|quarantine|reject)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1]))
}
// If sp is not specified, it defaults to the main policy (p tag)
// Return nil to indicate it's using the default
return nil
}
// extractDMARCPercentage extracts the percentage from a DMARC record
// Returns the pct tag value or nil if not specified (defaults to 100)
func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int {
// Look for pct=<number>
re := regexp.MustCompile(`pct=(\d+)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
// Convert string to int
var pct int
fmt.Sscanf(matches[1], "%d", &pct)
// Validate range (0-100)
if pct >= 0 && pct <= 100 {
return &pct
}
}
// Default is 100 if not specified
return nil
}
// validateDMARC performs basic DMARC record validation
func (d *DNSAnalyzer) validateDMARC(record string) bool {
// 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 *api.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 == api.DMARCRecordSpfAlignmentStrict {
score += 5
}
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.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,343 +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/api"
)
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: api.PtrTo("none"),
},
{
name: "Subdomain policy - quarantine",
record: "v=DMARC1; p=reject; sp=quarantine",
expectedPolicy: api.PtrTo("quarantine"),
},
{
name: "Subdomain policy - reject",
record: "v=DMARC1; p=quarantine; sp=reject",
expectedPolicy: api.PtrTo("reject"),
},
{
name: "No subdomain policy specified (defaults to main policy)",
record: "v=DMARC1; p=quarantine",
expectedPolicy: nil,
},
{
name: "Complex record with subdomain policy",
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100",
expectedPolicy: api.PtrTo("quarantine"),
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCSubdomainPolicy(tt.record)
if tt.expectedPolicy == nil {
if result != nil {
t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result)
}
} else {
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: api.PtrTo(100),
},
{
name: "Percentage - 50",
record: "v=DMARC1; p=quarantine; pct=50",
expectedPercentage: api.PtrTo(50),
},
{
name: "Percentage - 25",
record: "v=DMARC1; p=reject; pct=25",
expectedPercentage: api.PtrTo(25),
},
{
name: "Percentage - 0",
record: "v=DMARC1; p=none; pct=0",
expectedPercentage: api.PtrTo(0),
},
{
name: "No percentage specified (defaults to 100)",
record: "v=DMARC1; p=quarantine",
expectedPercentage: nil,
},
{
name: "Complex record with percentage",
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75",
expectedPercentage: api.PtrTo(75),
},
{
name: "Invalid percentage > 100 (ignored)",
record: "v=DMARC1; p=quarantine; pct=150",
expectedPercentage: nil,
},
{
name: "Invalid percentage < 0 (ignored)",
record: "v=DMARC1; p=quarantine; pct=-10",
expectedPercentage: nil,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
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/api"
)
// 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 *api.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,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"
"git.happydns.org/happyDeliver/internal/api"
)
// checkMXRecords looks up MX records for a domain
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
mxRecords, err := d.resolver.LookupMX(ctx, domain)
if err != nil {
return &[]api.MXRecord{
{
Valid: false,
Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
},
}
}
if len(mxRecords) == 0 {
return &[]api.MXRecord{
{
Valid: false,
Error: api.PtrTo("No MX records found"),
},
}
}
var results []api.MXRecord
for _, mx := range mxRecords {
results = append(results, api.MXRecord{
Host: mx.Host,
Priority: mx.Pref,
Valid: true,
})
}
return &results
}
func (d *DNSAnalyzer) calculateMXScore(results *api.DNSResults) (score int) {
// Having valid MX records is critical for email deliverability
// From domain MX records (half points) - needed for replies
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
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,367 +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/api"
)
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord {
visited := make(map[string]bool)
return d.resolveSPFRecords(domain, visited, 0, true)
}
// resolveSPFRecords recursively resolves SPF records including include: directives
// isMainRecord indicates if this is the primary domain's record (not an included one)
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]api.SPFRecord {
const maxDepth = 10 // Prevent infinite recursion
if depth > maxDepth {
return &[]api.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: api.PtrTo("Maximum SPF include depth exceeded"),
},
}
}
// Prevent circular references
if visited[domain] {
return &[]api.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 &[]api.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: api.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 &[]api.SPFRecord{
{
Domain: &domain,
Valid: false,
Error: api.PtrTo("No SPF record found"),
},
}
}
var results []api.SPFRecord
if spfCount > 1 {
results = append(results, api.SPFRecord{
Domain: &domain,
Record: &spfRecord,
Valid: false,
Error: api.PtrTo("Multiple SPF records found (RFC violation)"),
})
return &results
}
// Basic validation
validationErr := d.validateSPF(spfRecord, isMainRecord)
// Extract the "all" mechanism qualifier
var allQualifier *api.SPFRecordAllQualifier
var errMsg *string
if validationErr != nil {
errMsg = api.PtrTo(validationErr.Error())
} else {
// Extract qualifier from the "all" mechanism
if strings.HasSuffix(spfRecord, " -all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-"))
} else if strings.HasSuffix(spfRecord, " ~all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~"))
} else if strings.HasSuffix(spfRecord, " +all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
} else if strings.HasSuffix(spfRecord, " ?all") {
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?"))
} else if strings.HasSuffix(spfRecord, " all") {
// Implicit + qualifier (default)
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
}
}
results = append(results, api.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 *api.DNSResults) (score int) {
// SPF is essential for email authentication
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
// Find the main SPF record by skipping redirects
// 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,696 +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/api"
)
// 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 *api.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 api.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 *api.AuthenticationResults) *api.HeaderAnalysis {
if email == nil {
return nil
}
analysis := &api.HeaderAnalysis{}
// Check for proper MIME structure
analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0)
// Initialize headers map
headers := make(map[string]api.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) *api.HeaderCheck {
value := email.GetHeaderValue(headerName)
present := email.HasHeader(headerName) && value != ""
importanceEnum := api.HeaderCheckImportance(importance)
check := &api.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 *api.AuthenticationResults) *api.DomainAlignment {
alignment := &api.DomainAlignment{
Aligned: api.PtrTo(true),
RelaxedAligned: api.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 []api.DKIMDomainInfo
if authResults != nil && authResults.Dkim != nil {
for _, dkim := range *authResults.Dkim {
if dkim.Domain != nil && *dkim.Domain != "" {
domain := *dkim.Domain
orgDomain := h.getOrganizationalDomain(domain)
dkimDomains = append(dkimDomains, api.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) []api.HeaderIssue {
var issues []api.HeaderIssue
// Check for missing required headers
requiredHeaders := []string{"From", "Date", "Message-ID"}
for _, header := range requiredHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
issues = append(issues, api.HeaderIssue{
Header: header,
Severity: api.HeaderIssueSeverityCritical,
Message: fmt.Sprintf("Required header '%s' is missing", header),
Advice: api.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, api.HeaderIssue{
Header: "Message-ID",
Severity: api.HeaderIssueSeverityMedium,
Message: "Message-ID format is invalid",
Advice: api.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) []api.ReceivedHop {
if email == nil || email.Header == nil {
return nil
}
receivedHeaders := email.Header["Received"]
if len(receivedHeaders) == 0 {
return nil
}
var chain []api.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) *api.ReceivedHop {
hop := &api.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

@ -28,16 +28,9 @@ import (
"mime/multipart"
"net/mail"
"net/textproto"
"os"
"strings"
)
var hostname = ""
func init() {
hostname, _ = os.Hostname()
}
// EmailMessage represents a parsed email message
type EmailMessage struct {
Header mail.Header
@ -218,27 +211,8 @@ func buildRawHeaders(header mail.Header) string {
}
// GetAuthenticationResults extracts Authentication-Results headers
// If hostname is provided, only returns headers that begin with that hostname
func (e *EmailMessage) GetAuthenticationResults() []string {
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
// If no hostname specified, return all results
if hostname == "" {
return allResults
}
// Filter results that begin with the specified hostname
var filtered []string
prefix := hostname + ";"
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
return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
}
// GetSpamAssassinHeaders extracts SpamAssassin-related headers
@ -256,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
}
@ -328,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

@ -106,9 +106,6 @@ Content-Type: text/html; charset=utf-8
}
func TestGetAuthenticationResults(t *testing.T) {
// Force hostname
hostname = "example.com"
rawEmail := `From: sender@example.com
To: recipient@example.com
Subject: Test Email

View file

@ -27,21 +27,16 @@ import (
"net"
"regexp"
"strings"
"sync"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags.
type DNSListChecker struct {
Timeout time.Duration
Lists []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one
filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors
resolver *net.Resolver
informationalSet map[string]bool // Lists whose hits don't count toward the score
// RBLChecker checks IP addresses against DNS-based blacklists
type RBLChecker struct {
Timeout time.Duration
RBLs []string
resolver *net.Resolver
}
// DefaultRBLs is a list of commonly used RBL providers
@ -52,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][]api.BlacklistCheck // Map of IP -> list of checks for that IP
IPsChecked []string
ListedCount int // Total listings including informational entries
RelevantListedCount int // Listings on scoring (non-informational) lists only
// 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][]api.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
@ -136,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) ([]api.BlacklistCheck, int, error) {
if !r.isPublicIP(ip) {
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
}
checks := make([]api.BlacklistCheck, len(r.Lists))
var wg sync.WaitGroup
for i, list := range r.Lists {
wg.Add(1)
go func(i int, list string) {
defer wg.Done()
checks[i] = r.checkIP(ip, list)
}(i, list)
}
wg.Wait()
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
@ -205,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) {
@ -221,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
}
@ -238,109 +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) api.BlacklistCheck {
check := api.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 = api.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 = api.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 = api.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 = api.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) (int, string) {
// 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 {
return 100, ""
// No IPs to check, give benefit of doubt
return 2.0
}
scoringListCount := len(r.Lists) - len(r.informationalSet)
if scoringListCount <= 0 {
return 100, "A+"
listedCount := results.ListedCount
if listedCount == 0 {
return 2.0
} else if listedCount == 1 {
return 1.0
} else if listedCount <= 3 {
return 0.5
}
percentage := 100 - results.RelevantListedCount*100/scoringListCount
return percentage, ScoreToGrade(percentage)
return 0.0
}
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry
func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string {
// 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)
}
}
return checks
}
// 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,6 +23,7 @@ package analyzer
import (
"net/mail"
"strings"
"testing"
"time"
@ -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][]api.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][]api.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

@ -25,7 +25,6 @@ import (
"time"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/utils"
"github.com/google/uuid"
)
@ -33,12 +32,10 @@ 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
@ -46,18 +43,14 @@ func NewReportGenerator(
dnsTimeout time.Duration,
httpTimeout time.Duration,
rbls []string,
dnswls []string,
checkAllIPs bool,
) *ReportGenerator {
return &ReportGenerator{
authAnalyzer: NewAuthenticationAnalyzer(),
spamAnalyzer: NewSpamAssassinAnalyzer(),
rspamdAnalyzer: NewRspamdAnalyzer(),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs),
dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs),
rblChecker: NewRBLChecker(dnsTimeout, rbls),
contentAnalyzer: NewContentAnalyzer(httpTimeout),
headerAnalyzer: NewHeaderAnalyzer(),
scorer: NewDeliverabilityScorer(),
}
}
@ -65,13 +58,11 @@ func NewReportGenerator(
type AnalysisResults struct {
Email *EmailMessage
Authentication *api.AuthenticationResults
SpamAssassin *SpamAssassinResult
DNS *DNSResults
RBL *RBLResults
Content *ContentResults
DNS *api.DNSResults
Headers *api.HeaderAnalysis
RBL *DNSListResults
DNSWL *DNSListResults
SpamAssassin *api.SpamAssassinResult
Rspamd *api.RspamdResult
Score *ScoringResult
}
// AnalyzeEmail performs complete email analysis
@ -82,14 +73,20 @@ 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.Authentication, results.Headers)
results.RBL = r.rblChecker.CheckEmail(email)
results.DNSWL = r.dnswlChecker.CheckEmail(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email)
results.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
}
@ -99,198 +96,225 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
now := time.Now()
report := &api.Report{
Id: utils.UUIDToBase32(reportID),
TestId: utils.UUIDToBase32(testID),
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)
}
authScore := 0
var authGrade string
if results.Authentication != nil {
authScore, authGrade = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication)
}
contentScore := 0
var contentGrade string
if results.Content != nil {
contentScore, contentGrade = r.contentAnalyzer.CalculateContentScore(results.Content)
}
headerScore := 0
var headerGrade rune
if results.Headers != nil {
headerScore, headerGrade = r.headerAnalyzer.CalculateHeaderScore(results.Headers)
}
blacklistScore := 0
var blacklistGrade string
if results.RBL != nil {
blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL)
}
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)
}
// Build score summary
report.Summary = &api.ScoreSummary{
DnsScore: dnsScore,
DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade),
AuthenticationScore: authScore,
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
BlacklistScore: blacklistScore,
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
ContentScore: contentScore,
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
HeaderScore: headerScore,
HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade),
SpamScore: spamScore,
SpamGrade: api.ScoreSummarySpamGrade(spamGrade),
AuthenticationScore: results.Score.AuthScore,
SpamScore: results.Score.SpamScore,
BlacklistScore: results.Score.BlacklistScore,
ContentScore: results.Score.ContentScore,
HeaderScore: results.Score.HeaderScore,
}
// Collect all checks from different analyzers
checks := []api.Check{}
// Authentication checks
if results.Authentication != nil {
authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication)
checks = append(checks, authChecks...)
}
// DNS checks
if results.DNS != nil {
dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS)
checks = append(checks, dnsChecks...)
}
// RBL checks
if results.RBL != nil {
rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL)
checks = append(checks, rblChecks...)
}
// SpamAssassin checks
if results.SpamAssassin != nil {
spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin)
checks = append(checks, spamChecks...)
}
// 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 := api.SpamAssassinResultDeliverabilityGrade(saGrade)
results.SpamAssassin.DeliverabilityScore = api.PtrTo(saScore)
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
}
report.Spamassassin = results.SpamAssassin
// Add rspamd result with individual deliverability score
if results.Rspamd != nil {
rspamdGradeTyped := api.RspamdResultDeliverabilityGrade(rspamdGrade)
results.Rspamd.DeliverabilityScore = api.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 = api.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 {
@ -304,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,155 +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/api"
)
// 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{}
// NewRspamdAnalyzer creates a new rspamd analyzer
func NewRspamdAnalyzer() *RspamdAnalyzer {
return &RspamdAnalyzer{}
}
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
headers := email.GetRspamdHeaders()
if len(headers) == 0 {
return nil
}
result := &api.RspamdResult{
Symbols: make(map[string]api.RspamdSymbol),
}
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
if spamdResult, ok := headers["X-Spamd-Result"]; ok {
report := strings.ReplaceAll(spamdResult, "; ", ";\n")
result.Report = &report
a.parseSpamdResult(spamdResult, result)
}
// 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
}
// 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 *api.RspamdResult) {
// Extract score and threshold from the first line
// e.g. "default: False [-3.91 / 15.00]"
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
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 := api.RspamdSymbol{
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 *api.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,414 +0,0 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"bytes"
"net/mail"
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
analyzer := NewRspamdAnalyzer()
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()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &api.RspamdResult{
Symbols: make(map[string]api.RspamdSymbol),
}
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()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{Header: make(mail.Header)}
for k, v := range tt.headers {
email.Header[k] = []string{v}
}
result := analyzer.AnalyzeRspamd(email)
if result == nil {
t.Fatal("Expected non-nil result")
}
if result.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
}
if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
}
if result.IsSpam != tt.expectedIsSpam {
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
}
if tt.expectedServer != nil {
if result.Server == nil {
t.Errorf("Server = nil, want %q", *tt.expectedServer)
} else if *result.Server != *tt.expectedServer {
t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
}
}
if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
}
})
}
}
func TestCalculateRspamdScore(t *testing.T) {
tests := []struct {
name string
result *api.RspamdResult
expectedScore int
expectedGrade string
}{
{
name: "Nil result (rspamd not installed)",
result: nil,
expectedScore: 100,
expectedGrade: "",
},
{
name: "Score well below threshold",
result: &api.RspamdResult{
Score: -3.91,
Threshold: 15.00,
},
expectedScore: 100,
expectedGrade: "A+",
},
{
name: "Score at zero",
result: &api.RspamdResult{
Score: 0,
Threshold: 15.00,
},
// 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
expectedScore: 100,
expectedGrade: "A",
},
{
name: "Score at threshold (half of 2*threshold)",
result: &api.RspamdResult{
Score: 15.00,
Threshold: 15.00,
},
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
expectedScore: 50,
},
{
name: "Score above 2*threshold",
result: &api.RspamdResult{
Score: 31.00,
Threshold: 15.00,
},
expectedScore: 0,
expectedGrade: "F",
},
{
name: "Score exactly at 2*threshold",
result: &api.RspamdResult{
Score: 30.00,
Threshold: 15.00,
},
// 100 - round(30*100/30) = 100 - 100 = 0
expectedScore: 0,
expectedGrade: "F",
},
}
analyzer := NewRspamdAnalyzer()
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()
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,78 +22,524 @@
package analyzer
import (
"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 api.ReportGrade
func ScoreToReportGrade(score int) api.ReportGrade {
return api.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 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,7 +22,7 @@
package analyzer
import (
"math"
"fmt"
"regexp"
"strconv"
"strings"
@ -38,26 +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) *api.SpamAssassinResult {
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAssassinResult {
headers := email.GetSpamAssassinHeaders()
if len(headers) == 0 {
return nil
}
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.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
}
}
@ -68,13 +86,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
// Parse X-Spam-Report header for detailed test results
if reportHeader, ok := headers["X-Spam-Report"]; ok {
result.Report = api.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 = api.PtrTo(strings.TrimSpace(versionHeader))
result.Version = strings.TrimSpace(versionHeader)
}
return result
@ -82,7 +100,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
// 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 *api.SpamAssassinResult) {
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssassinResult) {
// Check if spam (first word)
parts := strings.SplitN(header, ",", 2)
if len(parts) > 0 {
@ -94,7 +112,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAs
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
}
}
@ -102,19 +120,19 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAs
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
}
}
@ -122,20 +140,17 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAs
// 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 *api.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 == "" {
@ -145,76 +160,186 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
// 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 := api.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] = api.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 := api.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 *api.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

@ -35,8 +35,8 @@ func TestParseSpamStatus(t *testing.T) {
name string
header string
expectedIsSpam bool
expectedScore float32
expectedReq float32
expectedScore float64
expectedReq float64
expectedTests []string
}{
{
@ -77,8 +77,8 @@ func TestParseSpamStatus(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail),
result := &SpamAssassinResult{
TestDetails: make(map[string]SpamTestDetail),
}
analyzer.parseSpamStatus(tt.header, result)
@ -91,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)
}
})
}
@ -115,27 +111,27 @@ func TestParseSpamReport(t *testing.T) {
`
analyzer := NewSpamAssassinAnalyzer()
result := &api.SpamAssassinResult{
TestDetails: make(map[string]api.SpamTestDetail),
result := &SpamAssassinResult{
TestDetails: make(map[string]SpamTestDetail),
}
analyzer.parseSpamReport(report, result)
expectedTests := map[string]api.SpamTestDetail{
expectedTests := map[string]SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
Description: api.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: api.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: api.PtrTo("All mail servers are trusted"),
Description: "All mail servers are trusted",
},
}
@ -148,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)
}
}
}
@ -157,63 +153,56 @@ func TestParseSpamReport(t *testing.T) {
func TestGetSpamAssassinScore(t *testing.T) {
tests := []struct {
name string
result *api.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: &api.SpamAssassinResult{
result: &SpamAssassinResult{
Score: -2.5,
RequiredScore: 5.0,
},
expectedScore: 100,
expectedScore: 2.0,
},
{
name: "Good score (below threshold)",
result: &api.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: &api.SpamAssassinResult{
Score: 5.0,
RequiredScore: 5.0,
},
expectedScore: 0, // >= threshold = 0
},
{
name: "Above threshold (spam)",
result: &api.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: &api.SpamAssassinResult{
result: &SpamAssassinResult{
Score: 12.0,
RequiredScore: 5.0,
},
expectedScore: 0, // >= threshold = 0
expectedScore: 0.5,
},
{
name: "Very high spam score",
result: &api.SpamAssassinResult{
result: &SpamAssassinResult{
Score: 20.0,
RequiredScore: 5.0,
},
expectedScore: 0, // >= threshold = 0
expectedScore: 0.0,
},
}
@ -221,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 {
@ -241,7 +230,7 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
name string
headers map[string]string
expectedIsSpam bool
expectedScore float32
expectedScore float64
expectedHasDetails bool
}{
{
@ -307,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{
@ -320,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,
@ -381,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")
}
@ -413,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)
}
@ -427,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,
@ -452,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)
}
}

1316
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,17 +16,17 @@
"generate:api": "openapi-ts"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@hey-api/openapi-ts": "0.86.10",
"@hey-api/openapi-ts": "0.85.2",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/node": "^24.0.0",
"eslint": "^9.38.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",

View file

@ -23,10 +23,9 @@ package web
import (
"encoding/json"
"flag"
"fmt"
"io"
"io/fs"
"io/ioutil"
"log"
"net/http"
"net/url"
@ -42,34 +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 appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
log.Println("Unable to generate JSON config to inject in web application")
} else {
@ -89,12 +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("/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))
@ -114,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)
@ -143,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())
}
@ -170,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,539 +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();
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>
<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>

View file

@ -1,77 +0,0 @@
<script lang="ts">
import type { BimiRecord } from "$lib/api/types.gen";
interface Props {
bimiRecord?: BimiRecord;
}
let { bimiRecord }: Props = $props();
</script>
{#if bimiRecord}
<div class="card mb-4" id="dns-bimi">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-0">
<i
class="bi"
class:bi-check-circle-fill={bimiRecord.valid}
class:text-success={bimiRecord.valid}
class:bi-x-circle-fill={!bimiRecord.valid}
class:text-danger={!bimiRecord.valid}
></i>
Brand Indicators for Message Identification
</h5>
<span class="badge bg-secondary">BIMI</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-2">
BIMI allows your brand logo to be displayed next to your emails in supported mail
clients. Requires strong DMARC enforcement (quarantine or reject policy) and
optionally a Verified Mark Certificate (VMC).
</p>
<hr />
<div class="mb-2">
<strong>Selector:</strong> <code>{bimiRecord.selector}</code>
<strong class="ms-3">Domain:</strong> <code>{bimiRecord.domain}</code>
</div>
<div class="mb-2">
<strong>Status:</strong>
{#if bimiRecord.valid}
<span class="badge bg-success">Valid</span>
{:else}
<span class="badge bg-danger">Invalid</span>
{/if}
</div>
{#if bimiRecord.logo_url}
<div class="mb-2">
<strong>Logo URL:</strong>
<a href={bimiRecord.logo_url} target="_blank" rel="noopener noreferrer"
>{bimiRecord.logo_url}</a
>
</div>
{/if}
{#if bimiRecord.vmc_url}
<div class="mb-2">
<strong>VMC URL:</strong>
<a href={bimiRecord.vmc_url} target="_blank" rel="noopener noreferrer"
>{bimiRecord.vmc_url}</a
>
</div>
{/if}
{#if bimiRecord.record}
<div class="mb-2">
<strong>Record:</strong><br />
<code class="d-block mt-1 text-break">{bimiRecord.record}</code>
</div>
{/if}
{#if bimiRecord.error}
<div class="text-danger">
<strong>Error:</strong>
{bimiRecord.error}
</div>
{/if}
</div>
</div>
{/if}

View file

@ -1,71 +0,0 @@
<script lang="ts">
import type { BlacklistCheck } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import { theme } from "$lib/stores/theme";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props {
blacklists: Record<string, BlacklistCheck[]>;
blacklistGrade?: string;
blacklistScore?: number;
}
let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
</script>
<div class="card shadow-sm" id="rbl-details">
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
<h4 class="mb-0 d-flex flex-wrap justify-content-between align-items-center">
<span>
<i class="bi bi-shield-exclamation me-2"></i>
Blacklist Checks
</span>
<span>
{#if blacklistScore !== undefined}
<span class="badge bg-{getScoreColorClass(blacklistScore)}">
{blacklistScore}%
</span>
{/if}
{#if blacklistGrade !== undefined}
<GradeDisplay grade={blacklistGrade} size="small" />
{/if}
</span>
</h4>
</div>
<div class="card-body">
<div class="row row-cols-1 row-cols-lg-2 overflow-auto">
{#each Object.entries(blacklists) as [ip, checks]}
<div class="col mb-3">
<h5 class="text-muted">
<i class="bi bi-hdd-network me-1"></i>
{ip}
</h5>
<table class="table table-sm table-striped table-hover mb-0">
<tbody>
{#each checks as check}
<tr>
<td title={check.response || "-"}>
<span
class="badge {check.listed
? 'bg-danger'
: check.error
? 'bg-dark'
: 'bg-success'}"
>
{check.error
? "Error"
: check.listed
? "Listed"
: "Clean"}
</span>
</td>
<td><code>{check.rbl}</code></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
</div>
</div>
</div>

View file

@ -0,0 +1,74 @@
<script lang="ts">
import type { Check } from "$lib/api/types.gen";
interface Props {
check: Check;
}
let { check }: Props = $props();
function getCheckIcon(status: string): string {
switch (status) {
case "pass":
return "bi-check-circle-fill text-success";
case "fail":
return "bi-x-circle-fill text-danger";
case "warn":
return "bi-exclamation-triangle-fill text-warning";
case "info":
return "bi-info-circle-fill text-info";
default:
return "bi-question-circle-fill text-secondary";
}
}
</script>
<div class="card mb-3">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="fs-4">
<i class={getCheckIcon(check.status)}></i>
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="fw-bold mb-1">{check.name}</h5>
<span class="badge bg-secondary text-capitalize">{check.category}</span>
</div>
<span class="badge bg-light text-dark">{check.score.toFixed(1)} pts</span>
</div>
<p class="mt-2 mb-2">{check.message}</p>
{#if check.advice}
<div class="alert alert-light border mb-2" role="alert">
<i class="bi bi-lightbulb me-2"></i>
<strong>Recommendation:</strong>
{check.advice}
</div>
{/if}
{#if check.details}
<details class="small text-muted">
<summary class="cursor-pointer">Technical Details</summary>
<pre class="mt-2 mb-0 small bg-light p-2 rounded">{check.details}</pre>
</details>
{/if}
</div>
</div>
</div>
</div>
<style>
.cursor-pointer {
cursor: pointer;
}
details summary {
user-select: none;
}
details summary:hover {
color: var(--bs-primary);
}
</style>

View file

@ -1,199 +0,0 @@
<script lang="ts">
import type { ContentAnalysis } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import { theme } from "$lib/stores/theme";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props {
contentAnalysis: ContentAnalysis;
contentGrade?: string;
contentScore?: number;
}
let { contentAnalysis, contentGrade, contentScore }: Props = $props();
</script>
<div class="card shadow-sm" id="content-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-file-text me-2"></i>
Content Analysis
</span>
<span>
{#if contentScore !== undefined}
<span class="badge bg-{getScoreColorClass(contentScore)}">
{contentScore}%
</span>
{/if}
{#if contentGrade !== undefined}
<GradeDisplay grade={contentGrade} size="small" />
{/if}
</span>
</h4>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i
class="bi {contentAnalysis.has_html
? 'bi-check-circle text-success'
: 'bi-x-circle text-muted'} me-2"
></i>
<span>HTML Part</span>
</div>
<div class="d-flex align-items-center mb-2">
<i
class="bi {contentAnalysis.has_plaintext
? 'bi-check-circle text-success'
: 'bi-x-circle text-muted'} me-2"
></i>
<span>Plaintext Part</span>
</div>
{#if typeof contentAnalysis.has_unsubscribe_link === "boolean"}
<div class="d-flex align-items-center mb-2">
<i
class="bi {contentAnalysis.has_unsubscribe_link
? 'bi-check-circle text-success'
: 'bi-x-circle text-warning'} me-2"
></i>
<span>Unsubscribe Link</span>
</div>
{/if}
</div>
<div class="col-md-6">
{#if contentAnalysis.text_to_image_ratio !== undefined}
<div class="mb-2">
<strong>Text to Image Ratio:</strong>
<span class="ms-2">{contentAnalysis.text_to_image_ratio.toFixed(2)}</span>
</div>
{/if}
{#if contentAnalysis.unsubscribe_methods && contentAnalysis.unsubscribe_methods.length > 0}
<div class="mb-2">
<strong>Unsubscribe Methods:</strong>
<div class="mt-1">
{#each contentAnalysis.unsubscribe_methods as method}
<span class="badge bg-info me-1">{method}</span>
{/each}
</div>
</div>
{/if}
</div>
</div>
{#if contentAnalysis.html_issues && contentAnalysis.html_issues.length > 0}
<div class="mt-3">
<h5>Content Issues</h5>
{#each contentAnalysis.html_issues as issue}
<div
class="alert alert-{issue.severity === 'critical' ||
issue.severity === 'high'
? 'danger'
: issue.severity === 'medium'
? 'warning'
: 'info'} py-2 px-3 mb-2"
>
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>{issue.type}</strong>
<div class="small">{issue.message}</div>
{#if issue.location}
<div class="small text-muted">{issue.location}</div>
{/if}
{#if issue.advice}
<div class="small mt-1">
<i class="bi bi-lightbulb me-1"></i>
{issue.advice}
</div>
{/if}
</div>
<span class="badge bg-secondary">{issue.severity}</span>
</div>
</div>
{/each}
</div>
{/if}
{#if contentAnalysis.links && contentAnalysis.links.length > 0}
<div class="mt-3">
<h5>Links ({contentAnalysis.links.length})</h5>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>URL</th>
<th>Status</th>
<th>HTTP Code</th>
</tr>
</thead>
<tbody>
{#each contentAnalysis.links as link}
<tr>
<td>
<small class="text-break">{link.url}</small>
{#if link.is_shortened}
<span class="badge bg-warning ms-1">Shortened</span>
{/if}
</td>
<td>
<span
class="badge {link.status === 'valid'
? 'bg-success'
: link.status === 'broken'
? 'bg-danger'
: 'bg-warning'}"
>
{link.status}
</span>
</td>
<td>{link.http_code || "-"}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{#if contentAnalysis.images && contentAnalysis.images.length > 0}
<div class="mt-3">
<h5>Images ({contentAnalysis.images.length})</h5>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Source</th>
<th>Alt Text</th>
<th>Tracking</th>
</tr>
</thead>
<tbody>
{#each contentAnalysis.images as image}
<tr>
<td><small class="text-break">{image.src || "-"}</small></td>
<td>
{#if image.has_alt}
<i class="bi bi-check-circle text-success me-1"></i>
<small>{image.alt_text || "Present"}</small>
{:else}
<i class="bi bi-x-circle text-warning me-1"></i>
<small class="text-muted">Missing</small>
{/if}
</td>
<td>
{#if image.is_tracking_pixel}
<span class="badge bg-info">Tracking Pixel</span>
{:else}
-
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
</div>

View file

@ -1,74 +0,0 @@
<script lang="ts">
import type { DkimRecord } from "$lib/api/types.gen";
interface Props {
dkimRecords?: DkimRecord[];
}
let { dkimRecords }: Props = $props();
// Compute overall validity
const dkimIsValid = $derived(dkimRecords?.reduce((acc, r) => acc && r.valid, true) ?? false);
</script>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-0">
<i
class="bi"
class:bi-check-circle-fill={dkimIsValid}
class:text-success={dkimIsValid}
class:bi-x-circle-fill={!dkimIsValid}
class:text-danger={!dkimIsValid}
></i>
DomainKeys Identified Mail
</h5>
<span class="badge bg-secondary">DKIM</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-0">
DKIM cryptographically signs your emails, proving they haven't been tampered with in
transit. Receiving servers verify this signature against your DNS records.
</p>
</div>
<div class="list-group list-group-flush">
{#if dkimRecords && dkimRecords.length > 0}
{#each dkimRecords as dkim}
<div class="list-group-item">
<div class="mb-2">
<strong>Selector:</strong> <code>{dkim.selector}</code>
<strong class="ms-3">Domain:</strong> <code>{dkim.domain}</code>
</div>
<div class="mb-2">
<strong>Status:</strong>
{#if dkim.valid}
<span class="badge bg-success">Valid</span>
{:else}
<span class="badge bg-danger">Invalid</span>
{/if}
</div>
{#if dkim.record}
<div class="mb-2">
<strong>Record:</strong><br />
<code class="d-block mt-1 text-break small text-truncate"
>{dkim.record}</code
>
</div>
{/if}
{#if dkim.error}
<div class="text-danger">
<strong>Error:</strong>
{dkim.error}
</div>
{/if}
</div>
{/each}
{:else}
<div class="list-group-item text-muted">
<i class="bi bi-exclamation-octagon me-2"></i>
No DKIM signatures found in this email. DKIM provides cryptographic authentication and
helps avoid spoofing, thus improving deliverability.
</div>
{/if}
</div>
</div>

View file

@ -1,271 +0,0 @@
<script lang="ts">
import type { DmarcRecord } from "$lib/api/types.gen";
interface Props {
dmarcRecord?: DmarcRecord;
}
let { dmarcRecord }: Props = $props();
// Helper function to determine policy strength
const policyStrength = (policy: string | undefined): number => {
const strength: Record<string, number> = { none: 0, quarantine: 1, reject: 2 };
return strength[policy || "none"] || 0;
};
</script>
{#if dmarcRecord}
<div class="card mb-4" id="dns-dmarc">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-0">
<i
class="bi"
class:bi-check-circle-fill={dmarcRecord.valid && dmarcRecord.policy != "none"}
class:text-success={dmarcRecord.valid && dmarcRecord.policy != "none"}
class:bi-arrow-up-circle-fill={dmarcRecord.valid &&
dmarcRecord.policy == "none"}
class:text-warning={dmarcRecord.valid && dmarcRecord.policy == "none"}
class:bi-x-circle-fill={!dmarcRecord.valid}
class:text-danger={!dmarcRecord.valid}
></i>
Domain-based Message Authentication
</h5>
<span class="badge bg-secondary">DMARC</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-2">
DMARC enforces domain alignment requirements (regardless of the policy). It builds
on SPF and DKIM by telling receiving servers what to do with emails that fail
authentication checks. It also enables reporting so you can monitor your email
security.
</p>
<hr />
<!-- Status -->
<div class="mb-2">
<strong>Status:</strong>
{#if dmarcRecord.valid}
<span class="badge bg-success">Valid</span>
{:else}
<span class="badge bg-danger">Invalid</span>
{/if}
</div>
<!-- Policy -->
{#if dmarcRecord.policy}
<div class="mb-3">
<strong>Policy:</strong>
<span
class="badge {dmarcRecord.policy === 'reject'
? 'bg-success'
: dmarcRecord.policy === 'quarantine'
? 'bg-warning'
: 'bg-secondary'}"
>
{dmarcRecord.policy}
</span>
{#if dmarcRecord.policy === "reject"}
<div class="alert alert-success mt-2 mb-0 small">
<i class="bi bi-shield-check me-1"></i>
<strong>Maximum protection</strong> — emails failing DMARC checks are rejected.
This provides the strongest defense against spoofing and phishing.
</div>
{:else if dmarcRecord.policy === "quarantine"}
<div class="alert alert-info mt-2 mb-0 small">
<i class="bi bi-check-circle me-1"></i>
<strong>Good protection</strong> — emails failing DMARC checks are
quarantined (sent to spam). This is a safe middle ground.<br />
<i class="bi bi-arrow-up-circle me-1"></i>
Once you've validated your configuration and ensured all legitimate mail
passes, consider upgrading to <code>p=reject</code> for maximum protection.
</div>
{:else if dmarcRecord.policy === "none"}
<div class="alert alert-warning mt-2 mb-0 small">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Monitoring only</strong> — emails failing DMARC are delivered
normally. This is only recommended during initial setup.<br />
<i class="bi bi-arrow-up-circle me-1"></i>
After monitoring reports, upgrade to <code>p=quarantine</code> or
<code>p=reject</code> to actively protect your domain.
</div>
{:else}
<div class="alert alert-danger mt-2 mb-0 small">
<i class="bi bi-x-circle me-1"></i>
<strong>Unknown policy</strong> — the policy value is not recognized. Valid
options are: none, quarantine, or reject.
</div>
{/if}
</div>
{/if}
<!-- Subdomain Policy -->
{#if dmarcRecord.subdomain_policy}
{@const mainStrength = policyStrength(dmarcRecord.policy)}
{@const subStrength = policyStrength(dmarcRecord.subdomain_policy)}
<div class="mb-3">
<strong>Subdomain Policy:</strong>
<span
class="badge {dmarcRecord.subdomain_policy === 'reject'
? 'bg-success'
: dmarcRecord.subdomain_policy === 'quarantine'
? 'bg-warning'
: 'bg-secondary'}"
>
{dmarcRecord.subdomain_policy}
</span>
{#if subStrength >= mainStrength}
<div class="alert alert-success mt-2 mb-0 small">
<i class="bi bi-check-circle me-1"></i>
<strong>Good configuration</strong> — subdomain policy is equal to or stricter
than main policy.
</div>
{:else}
<div class="alert alert-warning mt-2 mb-0 small">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Weaker subdomain protection</strong> — consider setting
<code>sp={dmarcRecord.policy}</code> to match your main policy for consistent
protection.
</div>
{/if}
</div>
{:else if dmarcRecord.policy}
<div class="mb-3">
<strong>Subdomain Policy:</strong>
<span class="badge bg-info">Inherits main policy</span>
<div class="alert alert-success mt-2 mb-0 small">
<i class="bi bi-check-circle me-1"></i>
<strong>Good default</strong> — subdomains inherit the main policy (<code
>{dmarcRecord.policy}</code
>) which provides consistent protection.
</div>
</div>
{/if}
<!-- Percentage -->
{#if dmarcRecord.percentage !== undefined}
<div class="mb-3">
<strong>Enforcement Percentage:</strong>
<span
class="badge {dmarcRecord.percentage === 100
? 'bg-success'
: dmarcRecord.percentage >= 50
? 'bg-warning'
: 'bg-danger'}"
>
{dmarcRecord.percentage}%
</span>
{#if dmarcRecord.percentage === 100}
<div class="alert alert-success mt-2 mb-0 small">
<i class="bi bi-check-circle me-1"></i>
<strong>Full enforcement</strong> — all messages are subject to DMARC policy.
This provides maximum protection.
</div>
{:else if dmarcRecord.percentage >= 50}
<div class="alert alert-warning mt-2 mb-0 small">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Partial enforcement</strong> — only {dmarcRecord.percentage}% of
messages are subject to DMARC policy. Consider increasing to
<code>pct=100</code> once you've validated your configuration.
</div>
{:else}
<div class="alert alert-danger mt-2 mb-0 small">
<i class="bi bi-x-circle me-1"></i>
<strong>Low enforcement</strong> — only {dmarcRecord.percentage}% of
messages are protected. Gradually increase to <code>pct=100</code> for full
protection.
</div>
{/if}
</div>
{:else if dmarcRecord.policy}
<div class="mb-3">
<strong>Enforcement Percentage:</strong>
<span class="badge bg-success">100% (default)</span>
<div class="alert alert-success mt-2 mb-0 small">
<i class="bi bi-check-circle me-1"></i>
<strong>Full enforcement</strong> — all messages are subject to DMARC policy
by default.
</div>
</div>
{/if}
<!-- SPF Alignment -->
{#if dmarcRecord.spf_alignment}
<div class="mb-3">
<strong>SPF Alignment:</strong>
<span
class="badge {dmarcRecord.spf_alignment === 'strict'
? 'bg-success'
: 'bg-info'}"
>
{dmarcRecord.spf_alignment}
</span>
{#if dmarcRecord.spf_alignment === "relaxed"}
<div class="alert alert-info mt-2 mb-0 small">
<i class="bi bi-check-circle me-1"></i>
<strong>Recommended for most senders</strong> — ensures legitimate
subdomain mail passes.<br />
<i class="bi bi-exclamation-triangle me-1"></i>
For maximum brand protection, consider strict alignment (<code
>aspf=s</code
>) once your sending domains are standardized.
</div>
{:else}
<div class="alert alert-success mt-2 mb-0 small">
<i class="bi bi-shield-check me-1"></i>
<strong>Maximum brand protection</strong> — only exact domain matches are
accepted. Ensure all legitimate mail comes from the exact From domain.
</div>
{/if}
</div>
{/if}
<!-- DKIM Alignment -->
{#if dmarcRecord.dkim_alignment}
<div class="mb-3">
<strong>DKIM Alignment:</strong>
<span
class="badge {dmarcRecord.dkim_alignment === 'strict'
? 'bg-success'
: 'bg-info'}"
>
{dmarcRecord.dkim_alignment}
</span>
{#if dmarcRecord.dkim_alignment === "relaxed"}
<div class="alert alert-info mt-2 mb-0 small">
<i class="bi bi-check-circle me-1"></i>
<strong>Recommended for most senders</strong> — ensures legitimate
subdomain mail passes.<br />
<i class="bi bi-exclamation-triangle me-1"></i>
For maximum brand protection, consider strict alignment (<code
>adkim=s</code
>) once your sending domains are standardized.
</div>
{:else}
<div class="alert alert-success mt-2 mb-0 small">
<i class="bi bi-shield-check me-1"></i>
<strong>Maximum brand protection</strong> — only exact domain matches are
accepted. Ensure all DKIM signatures use the exact From domain.
</div>
{/if}
</div>
{/if}
<!-- Record -->
{#if dmarcRecord.record}
<div class="mb-2">
<strong>Record:</strong><br />
<code class="d-block mt-1 text-break">{dmarcRecord.record}</code>
</div>
{/if}
<!-- Error -->
{#if dmarcRecord.error}
<div class="text-danger">
<strong>Error:</strong>
{dmarcRecord.error}
</div>
{/if}
</div>
</div>
{/if}

View file

@ -1,174 +0,0 @@
<script lang="ts">
import type { DnsResults, DomainAlignment, ReceivedHop } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import { theme } from "$lib/stores/theme";
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
import GradeDisplay from "./GradeDisplay.svelte";
import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
interface Props {
domainAlignment?: DomainAlignment;
dnsResults?: DnsResults;
dnsGrade?: string;
dnsScore?: number;
receivedChain?: ReceivedHop[];
domainOnly?: boolean; // If true, only shows domain-level DNS records (no PTR, no DKIM, simplified view)
}
let {
domainAlignment,
dnsResults,
dnsGrade,
dnsScore,
receivedChain,
domainOnly = false,
}: Props = $props();
// Extract sender IP from first hop
const senderIp = $derived(
receivedChain && receivedChain.length > 0 ? receivedChain[0].ip : undefined,
);
</script>
<div class="card shadow-sm" id="dns-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-diagram-3 me-2"></i>
DNS Records
</span>
<span>
{#if dnsScore !== undefined}
<span class="badge bg-{getScoreColorClass(dnsScore)}">
{dnsScore}%
</span>
{/if}
{#if dnsGrade !== undefined}
<GradeDisplay grade={dnsGrade} size="small" />
{/if}
</span>
</h4>
</div>
<div class="card-body">
{#if !dnsResults}
<p class="text-muted mb-0">No DNS results available</p>
{:else}
{#if dnsResults.errors && dnsResults.errors.length > 0}
<div class="alert alert-warning mb-3">
<strong>Errors:</strong>
<ul class="mb-0">
{#each dnsResults.errors as error}
<li>{error}</li>
{/each}
</ul>
</div>
{/if}
{#if !domainOnly}
<!-- Reverse IP Section -->
{#if receivedChain && receivedChain.length > 0}
<div class="mb-3 d-flex align-items-center gap-2">
<h4 class="mb-0 text-truncate">
Received from: <code
>{receivedChain[0].from} ({receivedChain[0].reverse || "Unknown"} [{receivedChain[0]
.ip}])</code
>
</h4>
</div>
{/if}
<!-- PTR Records Section -->
<PtrRecordsDisplay ptrRecords={dnsResults.ptr_records} {senderIp} />
<!-- Forward-Confirmed Reverse DNS -->
<PtrForwardRecordsDisplay
ptrRecords={dnsResults.ptr_records}
ptrForwardRecords={dnsResults.ptr_forward_records}
{senderIp}
/>
<hr class="my-4" />
<!-- Return-Path Domain Section -->
<div class="mb-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<h4 class="mb-0 text-truncate">
Return-Path Domain:
<code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
</h4>
{#if (domainAlignment && !domainAlignment.aligned && !domainAlignment.relaxed_aligned) || (domainAlignment && !domainAlignment.aligned && domainAlignment.relaxed_aligned && dnsResults.dmarc_record && dnsResults.dmarc_record.spf_alignment === "strict") || (!domainAlignment && dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain)}
<span class="badge bg-danger ms-2">
<i class="bi bi-exclamation-triangle-fill"></i> Differs from From domain
</span>
<small>
<i class="bi bi-chevron-right"></i>
<a href="#domain-alignment">See domain alignment</a>
</small>
{:else}
<span class="badge bg-success ms-2">Same as From domain</span>
{/if}
</div>
</div>
<!-- MX Records for Return-Path Domain -->
{#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0}
<MxRecordsDisplay
class="mb-4"
mxRecords={dnsResults.rp_mx_records}
title="Mail Exchange Records for Return-Path Domain"
description="These MX records handle bounce messages and non-delivery reports."
/>
{/if}
{/if}
<!-- SPF Records (for Return-Path Domain) -->
<SpfRecordsDisplay
spfRecords={dnsResults.spf_records}
dmarcRecord={dnsResults.dmarc_record}
/>
{#if !domainOnly}
<hr class="my-4" />
<!-- From Domain Section -->
<div class="mb-3 d-flex align-items-center gap-2">
<h4 class="mb-0 text-truncate">
From Domain: <code>{dnsResults.from_domain}</code>
</h4>
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
<span class="badge bg-danger ms-2">
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path
domain
</span>
{/if}
</div>
{/if}
<!-- MX Records for From Domain -->
{#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0}
<MxRecordsDisplay
class="mb-4"
mxRecords={dnsResults.from_mx_records}
title="Mail Exchange Records for From Domain"
description="These MX records handle replies to emails sent from this domain."
/>
{/if}
{#if !domainOnly}
<!-- DKIM Records -->
<DkimRecordsDisplay dkimRecords={dnsResults.dkim_records} />
{/if}
<!-- DMARC Record -->
<DmarcRecordDisplay dmarcRecord={dnsResults.dmarc_record} />
<!-- BIMI Record -->
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
{/if}
</div>
</div>

View file

@ -1,13 +1,10 @@
<script lang="ts">
import { theme } from "$lib/stores/theme";
interface Props {
email: string;
}
let { email }: Props = $props();
let copied = $state(false);
let inputElement: HTMLInputElement;
async function copyToClipboard() {
try {
@ -18,32 +15,13 @@
console.error("Failed to copy:", err);
}
}
function handleFocus(event: FocusEvent) {
const target = event.target as HTMLInputElement;
target.select();
copyToClipboard();
}
</script>
<div
class="rounded rounded-4 p-4"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<div class="input-group">
<input
bind:this={inputElement}
type="text"
class="form-control text-center fs-5 text-primary fw-bold font-monospace"
value={email}
readonly
onfocus={handleFocus}
/>
<div class="bg-light rounded p-4">
<div class="d-flex align-items-center justify-content-center gap-3">
<code class="fs-5 text-primary fw-bold">{email}</code>
<button
class="btn btn-outline-primary clipboard-btn"
class:btn-outline-primary={$theme === "light"}
class:btn-primary={$theme !== "light"}
class="btn btn-sm btn-outline-primary clipboard-btn"
onclick={copyToClipboard}
title="Copy to clipboard"
>
@ -51,7 +29,7 @@
</button>
</div>
{#if copied}
<small class="text-success d-block mt-2 text-center">
<small class="text-success d-block mt-2">
<i class="bi bi-check2"></i> Copied to clipboard!
</small>
{/if}

View file

@ -1,67 +0,0 @@
<script lang="ts">
import type { ReceivedHop } from "$lib/api/types.gen";
import { theme } from "$lib/stores/theme";
interface Props {
receivedChain: ReceivedHop[];
}
let { receivedChain }: Props = $props();
</script>
{#if receivedChain && receivedChain.length > 0}
<div class="card shadow-sm" id="email-path">
<div
class="card-header"
class:bg-white={$theme === "light"}
class:bg-dark={$theme !== "light"}
>
<h4 class="mb-0">
<i class="bi bi-pin-map me-2"></i>
Email Path
</h4>
</div>
<div class="list-group list-group-flush">
{#each receivedChain as hop, i}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">
<span class="badge bg-primary me-2">{receivedChain.length - i}</span>
{hop.reverse || "-"}
{#if hop.ip}<span class="text-muted">({hop.ip})</span>{/if}{hop.by ||
"Unknown"}
</h6>
<small class="text-muted" title={hop.timestamp}>
{hop.timestamp
? new Intl.DateTimeFormat("default", {
dateStyle: "long",
timeStyle: "short",
}).format(new Date(hop.timestamp))
: "-"}
</small>
</div>
{#if hop.with || hop.id || hop.from}
<p class="mb-1 small d-flex gap-3">
{#if hop.with}
<span>
<span class="text-muted">Protocol:</span>
<code>{hop.with}</code>
</span>
{/if}
{#if hop.id}
<span>
<span class="text-muted">ID:</span> <code>{hop.id}</code>
</span>
{/if}
{#if hop.from}
<span>
<span class="text-muted">Helo:</span> <code>{hop.from}</code>
</span>
{/if}
</p>
{/if}
</div>
{/each}
</div>
</div>
{/if}

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