Compare commits

...
Sign in to create a new pull request.

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
82 changed files with 10449 additions and 338 deletions

27
.dockerignore Normal file
View file

@ -0,0 +1,27 @@
# Git files
.git
.gitignore
# Documentation
*.md
!README.md
# Build artifacts
happyDeliver
*.db
*.sqlite
*.sqlite3
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# Logs files
logs/
# Test files
*_test.go
testdata/

22
.drone-manifest.yml Normal file
View file

@ -0,0 +1,22 @@
image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
{{/if}}
manifests:
- image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
- image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
platform:
architecture: arm64
os: linux
variant: v8
- image: happydomain/happydeliver:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
platform:
architecture: arm
os: linux
variant: v7

156
.drone.yml Normal file
View file

@ -0,0 +1,156 @@
---
kind: pipeline
type: docker
name: build-arm64
platform:
os: linux
arch: arm64
steps:
- name: frontend
image: node:22-alpine
commands:
- cd web
- npm install --network-timeout=100000
- npm run generate:api
- npm run build
- name: backend-commit
image: golang:1-alpine
commands:
- apk add --no-cache git
- go generate ./...
- 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
when:
event:
exclude:
- tag
- name: backend-tag
image: golang:1-alpine
commands:
- apk add --no-cache git
- go generate ./...
- 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
when:
event:
- tag
- name: build-commit macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- 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
GOARCH: arm64
when:
event:
exclude:
- tag
- name: build-tag macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- 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
GOARCH: arm64
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/happydeliver
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
---
kind: pipeline
type: docker
name: build-amd64
platform:
os: linux
arch: amd64
steps:
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/happydeliver
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
---
kind: pipeline
name: docker-manifest
platform:
os: linux
arch: arm64
steps:
- name: publish on Docker Hub
image: plugins/manifest
settings:
auto_tag: true
ignore_missing: true
spec: .drone-manifest.yml
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
depends_on:
- build-amd64
- build-arm64

3
.gitignore vendored
View file

@ -17,6 +17,9 @@ vendor/
.env.local
*.local
# Logs files
logs/
# Database files
*.db
*.sqlite

100
Dockerfile Normal file
View file

@ -0,0 +1,100 @@
# Multi-stage Dockerfile for happyDeliver with integrated MTA
# Stage 1: Build the Svelte application
FROM node:22-alpine AS nodebuild
WORKDIR /build
COPY api/ api/
COPY web/ web/
RUN yarn --cwd web install && \
yarn --cwd web run generate:api && \
yarn --cwd web --offline build
# Stage 2: Build the Go application
FROM golang:1-alpine AS builder
WORKDIR /build
# Install build dependencies
RUN apk add --no-cache ca-certificates git gcc musl-dev
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
COPY --from=nodebuild /build/web/build/ ./web/build/
# Build the application
RUN go generate ./... && \
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver
# Stage 3: Runtime image with Postfix and all filters
FROM alpine:3
# Install all required packages
RUN apk add --no-cache \
bash \
ca-certificates \
opendkim \
opendkim-utils \
opendmarc \
postfix \
postfix-pcre \
postfix-policyd-spf-perl \
spamassassin \
spamassassin-client \
supervisor \
sqlite \
tzdata \
&& rm -rf /var/cache/apk/*
# 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 && \
adduser -D -u 1000 -G happydeliver happydeliver
# Create necessary directories
RUN mkdir -p /etc/happydeliver \
/var/lib/happydeliver \
/var/log/happydeliver \
/var/spool/postfix/opendkim \
/var/spool/postfix/opendmarc \
/etc/opendkim/keys \
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
&& 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
RUN chmod +x /usr/local/bin/happyDeliver
# Copy configuration files
COPY docker/postfix/ /etc/postfix/
COPY docker/opendkim/ /etc/opendkim/
COPY docker/opendmarc/ /etc/opendmarc/
COPY docker/spamassassin/ /etc/mail/spamassassin/
COPY docker/supervisor/ /etc/supervisor/
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Expose ports
# 25 - SMTP
# 8080 - API server
EXPOSE 25 8080
# Default configuration
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_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"]
# Set entrypoint
ENTRYPOINT ["/entrypoint.sh"]
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]

217
README.md Normal file
View file

@ -0,0 +1,217 @@
# happyDeliver - Email Deliverability Tester
![banner](banner.webp)
An open-source email deliverability testing platform that analyzes test emails and provides detailed deliverability reports with scoring.
## Features
- **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**: 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
## Quick Start
### With Docker (Recommended)
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
- **OpenDKIM**: DKIM signature verification
- **OpenDMARC**: DMARC policy validation
- **SpamAssassin**: Spam scoring and analysis
- **happyDeliver API**: REST API server on port 8080
- **SQLite Database**: Persistent storage for tests and reports
#### 1. Using docker-compose
```bash
# Clone the repository
git clone https://git.nemunai.re/happyDomain/happyDeliver.git
cd happydeliver
# Edit docker-compose.yml to set your domain
# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables
# Build and start
docker-compose up -d
# View logs
docker-compose logs -f
# Stop
docker-compose down
```
The API will be available at `http://localhost:8080` and SMTP at `localhost:25`.
#### 2. Using docker build directly
```bash
# Build the image
docker build -t happydeliver:latest .
# Run the container
docker run -d \
--name happydeliver \
-p 25:25 \
-p 8080:8080 \
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
-e HOSTNAME=mail.yourdomain.com \
-v $(pwd)/data:/var/lib/happydeliver \
-v $(pwd)/logs:/var/log/happydeliver \
happydeliver:latest
```
### Manual Build
#### 1. Build
```bash
go generate
go build -o happyDeliver ./cmd/happyDeliver
```
### 2. Run the API Server
```bash
./happyDeliver server
```
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, ...
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:
#### Postfix LMTP Transport
You'll obtain the best results with a custom [transport rule](https://www.postfix.org/transport.5.html) using LMTP.
1. Start the happyDeliver server with LMTP enabled (default listens on `127.0.0.1:2525`):
```bash
./happyDeliver server
```
You can customize the LMTP address with the `-lmtp-addr` flag or in the config file.
2. Create the file `/etc/postfix/transport_happydeliver` with the following content:
```
# Transport map - route test emails to happyDeliver LMTP server
# Pattern: test-<uuid>@yourdomain.com -> LMTP on localhost: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`:
```diff
-transport_maps = texthash:/etc/postfix/transport
+transport_maps = texthash:/etc/postfix/transport, pcre:/etc/postfix/transport_happydeliver
```
If your `transport_maps` option is not set, just append this line:
```
transport_maps = pcre:/etc/postfix/transport_happydeliver
```
Note: to use the `pcre:` type, you need to have `postfix-pcre` installed.
4. Reload Postfix configuration:
```bash
postfix reload
```
#### 4. Create a Test
```bash
curl -X POST http://localhost:8080/api/test
```
Response:
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "test-550e8400@localhost",
"status": "pending",
"message": "Send your test email to the address above"
}
```
#### 5. Send Test Email
Send a test email to the address provided (you'll need to configure your MTA to route emails to the analyzer - see MTA Integration below).
#### 6. Get Report
```bash
curl http://localhost:8080/api/report/550e8400-e29b-41d4-a716-446655440000
```
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/test` | POST | Create a new deliverability test |
| `/api/test/{id}` | GET | Get test metadata and status |
| `/api/report/{id}` | GET | Get detailed analysis report |
| `/api/report/{id}/raw` | GET | Get raw annotated email |
| `/api/status` | GET | Service health and status |
## Email Analyzer (CLI Mode)
For manual testing or debugging, you can analyze emails from the command line:
```bash
cat email.eml | ./happyDeliver analyze
```
Or specify recipient explicitly:
```bash
cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
```
**Note:** In production, emails are delivered via LMTP (see integration instructions above).
## Scoring System
The deliverability score is calculated from 0 to 10 based on:
- **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
This project is funded through [NGI Zero Core](https://nlnet.nl/core), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/happyDomain).
[<img src="https://nlnet.nl/logo/banner.png" alt="NLnet foundation logo" width="20%" />](https://nlnet.nl)
[<img src="https://nlnet.nl/image/logos/NGI0_tag.svg" alt="NGI Zero Logo" width="20%" />](https://nlnet.nl/core)
## License
GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)

View file

@ -31,11 +31,11 @@ paths:
tags:
- tests
summary: Create a new deliverability test
description: Generates a unique test email address for sending test emails
description: Generates a unique test email address for sending test emails. No database record is created until an email is received.
operationId: createTest
responses:
'201':
description: Test created successfully
description: Test email address generated successfully
content:
application/json:
schema:
@ -51,8 +51,8 @@ paths:
get:
tags:
- tests
summary: Get test metadata
description: Retrieve test status and metadata
summary: Get test status
description: Check if a report exists for the given test ID. Returns pending if no report exists, analyzed if a report is available.
operationId: getTest
parameters:
- name: id
@ -63,13 +63,13 @@ paths:
format: uuid
responses:
'200':
description: Test metadata retrieved successfully
description: Test status retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Test'
'404':
description: Test not found
'500':
description: Internal server error
content:
application/json:
schema:
@ -168,8 +168,8 @@ components:
example: "test-550e8400@example.com"
status:
type: string
enum: [pending, received, analyzed, failed]
description: Current test status
enum: [pending, analyzed]
description: Current test status (pending = no report yet, analyzed = report available)
example: "analyzed"
created_at:
type: string
@ -353,6 +353,10 @@ components:
$ref: '#/components/schemas/AuthResult'
dmarc:
$ref: '#/components/schemas/AuthResult'
bimi:
$ref: '#/components/schemas/AuthResult'
arc:
$ref: '#/components/schemas/ARCResult'
AuthResult:
type: object
@ -376,6 +380,29 @@ components:
type: string
description: Additional details about the result
ARCResult:
type: object
required:
- result
properties:
result:
type: string
enum: [pass, fail, none]
description: Overall ARC chain validation result
example: "pass"
chain_valid:
type: boolean
description: Whether the ARC chain signatures are valid
example: true
chain_length:
type: integer
description: Number of ARC sets in the chain
example: 2
details:
type: string
description: Additional details about ARC validation
example: "ARC chain valid with 2 intermediaries"
SpamAssassinResult:
type: object
required:
@ -420,7 +447,7 @@ components:
example: "example.com"
record_type:
type: string
enum: [MX, SPF, DKIM, DMARC]
enum: [MX, SPF, DKIM, DMARC, BIMI]
description: DNS record type
example: "SPF"
status:

BIN
banner.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -22,14 +22,20 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"git.happydns.org/happyDeliver/internal/app"
"git.happydns.org/happyDeliver/internal/config"
)
const version = "0.1.0-dev"
func main() {
fmt.Println("Mail Tester - Email Deliverability Testing Platform")
fmt.Println("Version: 0.1.0-dev")
fmt.Println("happyDeliver - Email Deliverability Testing Platform")
fmt.Printf("Version: %s\n", version)
cfg, err := config.ConsolidateConfig()
if err != nil {
@ -40,13 +46,15 @@ func main() {
switch command {
case "server":
log.Println("Starting API server...")
// TODO: Start API server
if err := app.RunServer(cfg); err != nil {
log.Fatalf("Server error: %v", err)
}
case "analyze":
log.Println("Starting email analyzer...")
// TODO: Start email analyzer (LMTP/pipe mode)
if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil {
log.Fatalf("Analyzer error: %v", err)
}
case "version":
fmt.Println("0.1.0-dev")
fmt.Println(version)
default:
fmt.Printf("Unknown command: %s\n", command)
printUsage()
@ -57,7 +65,7 @@ func main() {
func printUsage() {
fmt.Println("\nCommand availables:")
fmt.Println(" happyDeliver server - Start the API server")
fmt.Println(" happyDeliver analyze [-recipient EMAIL] - Analyze email from stdin (MDA mode)")
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()

38
docker-compose.yml Normal file
View file

@ -0,0 +1,38 @@
services:
happydeliver:
build:
context: .
dockerfile: Dockerfile
image: happydeliver:latest
container_name: happydeliver
hostname: mail.happydeliver.local
environment:
# Set your domain and hostname
DOMAIN: happydeliver.local
HOSTNAME: mail.happydeliver.local
ports:
# SMTP port
- "25:25"
# API port
- "8080:8080"
volumes:
# Persistent database storage
- ./data:/var/lib/happydeliver
# Log files
- ./logs:/var/log/happydeliver
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:

164
docker/README.md Normal file
View file

@ -0,0 +1,164 @@
# happyDeliver Docker Configuration
This directory contains all configuration files for the all-in-one Docker container.
## Architecture
The Docker container integrates multiple components:
- **Postfix**: Mail Transfer Agent (MTA) that receives emails on port 25
- **OpenDKIM**: DKIM signature verification
- **OpenDMARC**: DMARC policy validation
- **SpamAssassin**: Spam scoring and content analysis
- **happyDeliver**: Go application (API server + email analyzer)
- **Supervisor**: Process manager that runs all services
## Directory Structure
```
docker/
├── postfix/
│ ├── main.cf # Postfix main configuration
│ ├── master.cf # Postfix service definitions
│ └── transport_maps # Email routing rules
├── opendkim/
│ └── opendkim.conf # DKIM verification config
├── opendmarc/
│ └── opendmarc.conf # DMARC validation config
├── spamassassin/
│ └── local.cf # SpamAssassin rules and scoring
├── supervisor/
│ └── supervisord.conf # Supervisor service definitions
├── entrypoint.sh # Container initialization script
└── config.docker.yaml # happyDeliver default config
```
## Configuration Details
### Postfix (postfix/)
**main.cf**: Core Postfix settings
- Configures hostname, domain, and network interfaces
- Sets up milter integration for OpenDKIM and OpenDMARC
- Configures SPF policy checking
- Routes emails through SpamAssassin content filter
- Uses transport_maps to route test emails to happyDeliver
**master.cf**: Service definitions
- Defines SMTP service with content filtering
- Sets up SPF policy service (postfix-policyd-spf-perl)
- Configures SpamAssassin content filter
- Defines happydeliver pipe for email analysis
**transport_maps**: PCRE-based routing
- Matches test-UUID@domain emails
- Routes them to the happydeliver pipe
### OpenDKIM (opendkim/)
**opendkim.conf**: DKIM verification settings
- Operates in verification-only mode
- Adds Authentication-Results headers
- Socket communication with Postfix via milter
- 5-second DNS timeout
### OpenDMARC (opendmarc/)
**opendmarc.conf**: DMARC validation settings
- Validates DMARC policies
- Adds results to Authentication-Results headers
- Does not reject emails (analysis mode only)
- Socket communication with Postfix via milter
### SpamAssassin (spamassassin/)
**local.cf**: Spam detection rules
- Enables network tests (RBL checks)
- SPF and DKIM checking
- Required score: 5.0 (standard threshold)
- Adds detailed spam report headers
- 5-second RBL timeout
### Supervisor (supervisor/)
**supervisord.conf**: Service orchestration
- Runs all services as daemons
- Start order: OpenDKIM → OpenDMARC → SpamAssassin → Postfix → API
- Automatic restart on failure
- Centralized logging
### Entrypoint Script (entrypoint.sh)
Initialization script that:
1. Creates required directories and sets permissions
2. Replaces configuration placeholders with environment variables
3. Initializes Postfix (aliases, transport maps)
4. Updates SpamAssassin rules
5. Starts Supervisor to launch all services
### happyDeliver Config (config.docker.yaml)
Default configuration for the Docker environment:
- API server on 0.0.0.0:8080
- SQLite database at /var/lib/happydeliver/happydeliver.db
- Configurable domain for test emails
- RBL servers for blacklist checking
- Timeouts for DNS and HTTP checks
## Environment Variables
The container accepts these environment variables:
- `DOMAIN`: Email domain for test addresses (default: happydeliver.local)
- `HOSTNAME`: Container hostname (default: mail.happydeliver.local)
Example:
```bash
docker run -e DOMAIN=example.com -e HOSTNAME=mail.example.com ...
```
## Volumes
**Required volumes:**
- `/var/lib/happydeliver`: Database and persistent data
- `/var/log/happydeliver`: Log files from all services
**Optional volumes:**
- `/etc/happydeliver/config.yaml`: Custom configuration file
## Ports
- **25**: SMTP (Postfix)
- **8080**: HTTP API (happyDeliver)
## Service Startup Order
Supervisor ensures services start in the correct order:
1. **OpenDKIM** (priority 10): DKIM verification milter
2. **OpenDMARC** (priority 11): DMARC validation milter
3. **SpamAssassin** (priority 12): Spam scoring daemon
4. **Postfix** (priority 20): MTA that uses the above services
5. **happyDeliver API** (priority 30): REST API server
## Email Processing Flow
1. Email arrives at Postfix on port 25
2. Postfix sends to OpenDKIM milter
- Verifies DKIM signature
- Adds `Authentication-Results: ... dkim=pass/fail`
3. Postfix sends to OpenDMARC milter
- Validates DMARC policy
- Adds `Authentication-Results: ... dmarc=pass/fail`
4. Postfix routes through SpamAssassin content filter
- Checks SPF record
- Scores email for spam
- Adds `X-Spam-Status` and `X-Spam-Report` headers
5. Postfix checks transport_maps
- If recipient matches test-UUID pattern, route to happydeliver pipe
6. happyDeliver analyzer receives email
- Extracts test ID from recipient
- Parses all headers added by filters
- Performs additional analysis (DNS, RBL, content)
- Generates deliverability score
- Stores report in database

66
docker/entrypoint.sh Normal file
View file

@ -0,0 +1,66 @@
#!/bin/bash
set -e
echo "Starting happyDeliver container..."
# Get environment variables with defaults
HOSTNAME="${HOSTNAME:-mail.happydeliver.local}"
HAPPYDELIVER_DOMAIN="${HAPPYDELIVER_DOMAIN:-happydeliver.local}"
echo "Hostname: $HOSTNAME"
echo "Domain: $HAPPYDELIVER_DOMAIN"
# Create runtime directories
mkdir -p /var/run/opendkim /var/run/opendmarc
chown opendkim:postfix /var/run/opendkim
chown opendmarc:postfix /var/run/opendmarc
# 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
chown happydeliver:happydeliver /var/log/happydeliver
# 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
# Replace placeholders in OpenDMARC configuration
sed -i "s/__HOSTNAME__/${HOSTNAME}/g" /etc/opendmarc/opendmarc.conf
# Initialize Postfix aliases
if [ -f /etc/postfix/aliases ]; then
echo "Initializing Postfix aliases..."
postalias /etc/postfix/aliases || true
fi
# Compile transport maps
if [ -f /etc/postfix/transport_maps ]; then
echo "Compiling transport maps..."
postmap /etc/postfix/transport_maps
fi
# Update SpamAssassin rules
echo "Updating SpamAssassin rules..."
sa-update || echo "SpamAssassin rules update failed (might be first run)"
# Compile SpamAssassin rules
sa-compile || echo "SpamAssassin compilation skipped"
# Initialize database if it doesn't exist
if [ ! -f /var/lib/happydeliver/happydeliver.db ]; then
echo "Database will be initialized on first API startup..."
fi
# Set proper permissions
chown -R happydeliver:happydeliver /var/lib/happydeliver
echo "Configuration complete, starting services..."
# Execute the main command (supervisord)
exec "$@"

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

10
docker/postfix/aliases Normal file
View file

@ -0,0 +1,10 @@
# Postfix aliases for happyDeliver
# This file is processed by postalias to create aliases.db
# Standard aliases
postmaster: root
abuse: root
mailer-daemon: postmaster
# Root mail can be redirected if needed
# root: admin@example.com

41
docker/postfix/main.cf Normal file
View file

@ -0,0 +1,41 @@
# Postfix main configuration for happyDeliver
# This configuration receives emails and routes them through authentication filters
# Basic settings
compatibility_level = 3.6
myhostname = __HOSTNAME__
mydomain = __DOMAIN__
myorigin = $mydomain
inet_interfaces = all
inet_protocols = ipv4
# Recipient settings
mydestination = $myhostname, localhost.$mydomain, localhost
mynetworks = 127.0.0.0/8 [::1]/128
# Relay settings - accept mail for our test domain
relay_domains = $mydomain
# Queue and size limits
message_size_limit = 10485760
mailbox_size_limit = 0
queue_minfree = 50000000
# Transport maps - route test emails to happyDeliver analyzer
transport_maps = pcre:/etc/postfix/transport_maps
# Authentication milters
# OpenDKIM for DKIM verification
milter_default_action = accept
milter_protocol = 6
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,
check_policy_service unix:private/policy-spf
# Logging
debug_peer_level = 2

83
docker/postfix/master.cf Normal file
View file

@ -0,0 +1,83 @@
# Postfix master process configuration for happyDeliver
# SMTP service
smtp inet n - n - - smtpd
-o content_filter=spamassassin
# Pickup service
pickup unix n - n 60 1 pickup
# Cleanup service
cleanup unix n - n - 0 cleanup
# Queue manager
qmgr unix n - n 300 1 qmgr
# Rewrite service
rewrite unix - - n - - trivial-rewrite
# Bounce service
bounce unix - - n - 0 bounce
# Defer service
defer unix - - n - 0 bounce
# Trace service
trace unix - - n - 0 bounce
# Verify service
verify unix - - n - 1 verify
# Flush service
flush unix n - n 1000? 0 flush
# Proxymap service
proxymap unix - - n - - proxymap
# Proxywrite service
proxywrite unix - - n - 1 proxymap
# SMTP client
smtp unix - - n - - smtp
# Relay service
relay unix - - n - - smtp
# Showq service
showq unix n - n - - showq
# Error service
error unix - - n - - error
# Retry service
retry unix - - n - - error
# Discard service
discard unix - - n - - discard
# Local delivery
local unix - n n - - local
# Virtual delivery
virtual unix - n n - - virtual
# LMTP delivery
lmtp unix - - n - - lmtp
# Anvil service
anvil unix - - n - 1 anvil
# Scache service
scache unix - - n - 1 scache
# Maildrop service
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

@ -0,0 +1,4 @@
# Transport map - route test emails to happyDeliver LMTP server
# Pattern: test-<uuid>@domain.com -> LMTP on localhost: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

@ -0,0 +1,50 @@
# SpamAssassin configuration for happyDeliver
# Scores emails for spam characteristics
# Network tests
# Enable network tests for RBL checks, Razor, Pyzor, etc.
use_network_tests 1
# RBL checks
# Enable DNS-based blacklist checks
use_rbls 1
# SPF checking
use_spf 1
# DKIM checking
use_dkim 1
# Bayes filtering
# Disable bayes learning (we're not maintaining a persistent spam database)
use_bayes 0
bayes_auto_learn 0
# Scoring thresholds
# Lower thresholds for testing purposes
required_score 5.0
# Report settings
# Add detailed spam report to headers
add_header all Status "_YESNO_, score=_SCORE_ required=_REQD_ tests=_TESTS_ autolearn=_AUTOLEARN_ version=_VERSION_"
add_header all Level _STARS(*)_
add_header all Report _REPORT_
# Rewrite subject line
rewrite_header Subject [SPAM:_SCORE_]
# Whitelisting and blacklisting
# Accept all mail for analysis (don't reject)
skip_rbl_checks 0
# Language settings
# Accept all languages
ok_languages all
# Network timeout
rbl_timeout 5
# User preferences
# Don't use user-specific rules
user_scores_dsn_timeout 3
user_scores_sql_override 0

View file

@ -0,0 +1,76 @@
[supervisord]
nodaemon=true
user=root
logfile=/var/log/happydeliver/supervisord.log
pidfile=/run/supervisord.pid
loglevel=info
[unix_http_server]
file=/run/supervisord.sock
chmod=0700
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///run/supervisord.sock
# syslogd service
[program:syslogd]
command=/sbin/syslogd -n
autostart=true
autorestart=true
priority=9
# 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/opendkim.log
stderr_logfile=/var/log/happydeliver/opendkim_error.log
user=opendkim
group=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/opendmarc.log
stderr_logfile=/var/log/happydeliver/opendmarc_error.log
user=opendmarc
group=mail
# SpamAssassin daemon
[program:spamd]
command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid
autostart=true
autorestart=true
priority=12
stdout_logfile=/var/log/happydeliver/spamd.log
stderr_logfile=/var/log/happydeliver/spamd_error.log
user=root
# Postfix service
[program:postfix]
command=/usr/sbin/postfix start-fg
autostart=true
autorestart=true
priority=20
stdout_logfile=/var/log/happydeliver/postfix.log
stderr_logfile=/var/log/happydeliver/postfix_error.log
user=root
# happyDeliver API server
[program:happydeliver-api]
command=/usr/local/bin/happyDeliver server -config /etc/happydeliver/config.yaml
autostart=true
autorestart=true
priority=30
stdout_logfile=/var/log/happydeliver/api.log
stderr_logfile=/var/log/happydeliver/api_error.log
user=happydeliver
environment=GIN_MODE="release"

28
go.mod
View file

@ -3,11 +3,15 @@ module git.happydns.org/happyDeliver
go 1.24.6
require (
github.com/emersion/go-smtp v0.24.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.42.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.0
)
require (
@ -16,6 +20,7 @@ require (
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // 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.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
@ -25,12 +30,19 @@ require (
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.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
github.com/josharian/intern v1.0.0 // indirect
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.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // 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
@ -40,7 +52,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // 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
@ -48,12 +60,12 @@ require (
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.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

57
go.sum
View file

@ -17,6 +17,10 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
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=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
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=
@ -68,6 +72,18 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
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.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=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@ -88,6 +104,8 @@ 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.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=
@ -126,8 +144,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
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=
@ -143,6 +161,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@ -162,11 +181,11 @@ 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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
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=
@ -174,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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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=
@ -196,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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
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=
@ -242,3 +261,9 @@ gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

194
internal/api/handlers.go Normal file
View file

@ -0,0 +1,194 @@
// 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 api
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
openapi_types "github.com/oapi-codegen/runtime/types"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/storage"
)
// APIHandler implements the ServerInterface for handling API requests
type APIHandler struct {
storage storage.Storage
config *config.Config
startTime time.Time
}
// NewAPIHandler creates a new API handler
func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler {
return &APIHandler{
storage: store,
config: cfg,
startTime: time.Now(),
}
}
// CreateTest creates a new deliverability test
// (POST /test)
func (h *APIHandler) CreateTest(c *gin.Context) {
// Generate a unique test ID (no database record created)
testID := uuid.New()
// Generate test email address
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
testID.String(),
h.config.Email.Domain,
)
// Return response
c.JSON(http.StatusCreated, TestResponse{
Id: testID,
Email: openapi_types.Email(email),
Status: TestResponseStatusPending,
Message: stringPtr("Send your test email to the address above"),
})
}
// GetTest retrieves test metadata
// (GET /test/{id})
func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
// Check if a report exists for this test ID
reportExists, err := h.storage.ReportExists(id)
if err != nil {
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to check test status",
Details: stringPtr(err.Error()),
})
return
}
// Determine status based on report existence
var apiStatus TestStatus
if reportExists {
apiStatus = TestStatusAnalyzed
} else {
apiStatus = TestStatusPending
}
// Generate test email address
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
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,
CreatedAt: now,
UpdatedAt: &now,
})
}
// GetReport retrieves the detailed analysis report
// (GET /report/{id})
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{
Error: "not_found",
Message: "Report not found",
})
return
}
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve report",
Details: stringPtr(err.Error()),
})
return
}
// Return raw JSON directly
c.Data(http.StatusOK, "application/json", reportJSON)
}
// GetRawEmail retrieves the raw annotated email
// (GET /report/{id}/raw)
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{
Error: "not_found",
Message: "Email not found",
})
return
}
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve raw email",
Details: stringPtr(err.Error()),
})
return
}
c.Data(http.StatusOK, "text/plain", rawEmail)
}
// GetStatus retrieves service health status
// (GET /status)
func (h *APIHandler) GetStatus(c *gin.Context) {
// Calculate uptime
uptime := int(time.Since(h.startTime).Seconds())
// Check database connectivity by trying to check if a report exists
dbStatus := StatusComponentsDatabaseUp
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
dbStatus = StatusComponentsDatabaseDown
}
// Determine overall status
overallStatus := Healthy
if dbStatus == StatusComponentsDatabaseDown {
overallStatus = Unhealthy
}
mtaStatus := StatusComponentsMtaUp
c.JSON(http.StatusOK, Status{
Status: overallStatus,
Version: "0.1.0-dev",
Components: &struct {
Database *StatusComponentsDatabase `json:"database,omitempty"`
Mta *StatusComponentsMta `json:"mta,omitempty"`
}{
Database: &dbStatus,
Mta: &mtaStatus,
},
Uptime: &uptime,
})
}

View file

@ -21,6 +21,10 @@
package api
func stringPtr(s string) *string {
return &s
}
// PtrTo returns a pointer to the provided value
func PtrTo[T any](v T) *T {
return &v

108
internal/app/cleanup.go Normal file
View file

@ -0,0 +1,108 @@
// 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 (
"context"
"log"
"time"
"git.happydns.org/happyDeliver/internal/storage"
)
const (
// How often to run the cleanup check
cleanupInterval = 1 * time.Hour
)
// CleanupService handles periodic cleanup of old reports
type CleanupService struct {
store storage.Storage
retention time.Duration
ticker *time.Ticker
done chan struct{}
}
// NewCleanupService creates a new cleanup service
func NewCleanupService(store storage.Storage, retention time.Duration) *CleanupService {
return &CleanupService{
store: store,
retention: retention,
done: make(chan struct{}),
}
}
// Start begins the cleanup service in a background goroutine
func (s *CleanupService) Start(ctx context.Context) {
if s.retention <= 0 {
log.Println("Report retention is disabled (keeping reports forever)")
return
}
log.Printf("Starting cleanup service: will delete reports older than %s", s.retention)
// Run cleanup immediately on startup
s.runCleanup()
// Then run periodically
s.ticker = time.NewTicker(cleanupInterval)
go func() {
for {
select {
case <-s.ticker.C:
s.runCleanup()
case <-ctx.Done():
s.Stop()
return
case <-s.done:
return
}
}
}()
}
// Stop stops the cleanup service
func (s *CleanupService) Stop() {
if s.ticker != nil {
s.ticker.Stop()
}
close(s.done)
}
// runCleanup performs the actual cleanup operation
func (s *CleanupService) runCleanup() {
cutoffTime := time.Now().Add(-s.retention)
log.Printf("Running cleanup: deleting reports older than %s", cutoffTime.Format(time.RFC3339))
deleted, err := s.store.DeleteOldReports(cutoffTime)
if err != nil {
log.Printf("Error during cleanup: %v", err)
return
}
if deleted > 0 {
log.Printf("Cleanup completed: deleted %d old report(s)", deleted)
} else {
log.Printf("Cleanup completed: no old reports to delete")
}
}

View file

@ -0,0 +1,143 @@
// 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"
"flag"
"fmt"
"io"
"log"
"strings"
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/pkg/analyzer"
)
// RunAnalyzer runs the standalone email analyzer (from stdin)
func RunAnalyzer(cfg *config.Config, args []string, reader io.Reader, writer io.Writer) error {
// Parse command-line flags
fs := flag.NewFlagSet("analyze", flag.ExitOnError)
jsonOutput := fs.Bool("json", false, "Output results as JSON")
if err := fs.Parse(args); err != nil {
return err
}
if err := cfg.Validate(); err != nil {
return err
}
log.Printf("Email analyzer ready, reading from stdin...")
// Read email from stdin
emailData, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("failed to read email from stdin: %w", err)
}
// Create analyzer with configuration
emailAnalyzer := analyzer.NewEmailAnalyzer(cfg)
// Analyze the email (using a dummy test ID for standalone mode)
result, err := emailAnalyzer.AnalyzeEmailBytes(emailData, uuid.New())
if err != nil {
return fmt.Errorf("failed to analyze email: %w", err)
}
log.Printf("Analyzing email from: %s", result.Email.From)
// Output results
if *jsonOutput {
return outputJSON(result, writer)
}
return outputHumanReadable(result, emailAnalyzer, writer)
}
// outputJSON outputs the report as JSON
func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error {
reportJSON, err := json.MarshalIndent(result.Report, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal report: %w", err)
}
fmt.Fprintln(writer, string(reportJSON))
return nil
}
// outputHumanReadable outputs a human-readable summary
func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error {
// Header
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT")
fmt.Fprintln(writer, strings.Repeat("=", 70))
// Score summary
summary := emailAnalyzer.GetScoreSummaryText(result)
fmt.Fprintln(writer, summary)
// 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)
}
// Print checks by category
categoryOrder := []api.CheckCategory{
api.Authentication,
api.Dns,
api.Blacklist,
api.Content,
api.Headers,
}
for _, category := range categoryOrder {
checks, ok := categories[category]
if !ok || len(checks) == 0 {
continue
}
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 = "âš "
}
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)
}
}
}
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
return nil
}

89
internal/app/server.go Normal file
View file

@ -0,0 +1,89 @@
// 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 (
"context"
"log"
"os"
"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/web"
)
// RunServer starts the API server and LMTP server
func RunServer(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 err
}
defer store.Close()
log.Printf("Connected to %s database", cfg.Database.Type)
// Start cleanup service for old reports
ctx := context.Background()
cleanupSvc := NewCleanupService(store, cfg.ReportRetention)
cleanupSvc.Start(ctx)
defer cleanupSvc.Stop()
// Start LMTP server in background
go func() {
if err := lmtp.StartServer(cfg.Email.LMTPAddr, store, cfg); err != nil {
log.Fatalf("Failed to start LMTP server: %v", err)
}
}()
// Create API handler
handler := api.NewAPIHandler(store, cfg)
// Set up Gin router
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.Default()
// Register API routes
apiGroup := router.Group("/api")
api.RegisterHandlers(apiGroup, handler)
web.DeclareRoutes(cfg, router)
// Start API server
log.Printf("Starting API server on %s", cfg.Bind)
log.Printf("Test email domain: %s", cfg.Email.Domain)
if err := router.Run(cfg.Bind); err != nil {
return err
}
return nil
}

View file

@ -33,9 +33,11 @@ func declareFlags(o *Config) {
flag.StringVar(&o.Database.DSN, "database-dsn", o.Database.DSN, "Database DSN or path")
flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address")
flag.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.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -40,6 +40,7 @@ type Config struct {
Database DatabaseConfig
Email EmailConfig
Analysis AnalysisConfig
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
}
// DatabaseConfig contains database connection settings
@ -52,6 +53,7 @@ type DatabaseConfig struct {
type EmailConfig struct {
Domain string
TestAddressPrefix string
LMTPAddr string
}
// AnalysisConfig contains timeout and behavior settings for email analysis
@ -66,6 +68,7 @@ func DefaultConfig() *Config {
return &Config{
DevProxy: "",
Bind: ":8080",
ReportRetention: 0, // Keep reports forever by default
Database: DatabaseConfig{
Type: "sqlite",
DSN: "happydeliver.db",
@ -73,6 +76,7 @@ func DefaultConfig() *Config {
Email: EmailConfig{
Domain: "happydeliver.local",
TestAddressPrefix: "test-",
LMTPAddr: "127.0.0.1:2525",
},
Analysis: AnalysisConfig{
DNSTimeout: 5 * time.Second,

144
internal/lmtp/server.go Normal file
View file

@ -0,0 +1,144 @@
// 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 lmtp
import (
"fmt"
"io"
"log"
"net"
"github.com/emersion/go-smtp"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/receiver"
"git.happydns.org/happyDeliver/internal/storage"
)
// Backend implements smtp.Backend for LMTP server
type Backend struct {
receiver *receiver.EmailReceiver
config *config.Config
}
// NewBackend creates a new LMTP backend
func NewBackend(store storage.Storage, cfg *config.Config) *Backend {
return &Backend{
receiver: receiver.NewEmailReceiver(store, cfg),
config: cfg,
}
}
// NewSession creates a new SMTP/LMTP session
func (b *Backend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
return &Session{backend: b}, nil
}
// Session implements smtp.Session for handling LMTP connections
type Session struct {
backend *Backend
from string
recipients []string
}
// AuthPlain implements PLAIN authentication (not used for local LMTP)
func (s *Session) AuthPlain(username, password string) error {
// No authentication required for local LMTP
return nil
}
// Mail is called when MAIL FROM command is received
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
log.Printf("LMTP: MAIL FROM: %s", from)
s.from = from
return nil
}
// Rcpt is called when RCPT TO command is received
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
log.Printf("LMTP: RCPT TO: %s", to)
s.recipients = append(s.recipients, to)
return nil
}
// Data is called when DATA command is received and email content is being transferred
func (s *Session) Data(r io.Reader) error {
log.Printf("LMTP: Receiving message data for %d recipient(s)", len(s.recipients))
// Read the entire email
emailData, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("failed to read email data: %w", err)
}
log.Printf("LMTP: Received %d bytes", len(emailData))
// Process email for each recipient
// LMTP requires per-recipient status, but go-smtp handles this internally
for _, recipient := range s.recipients {
if err := s.backend.receiver.ProcessEmailBytes(emailData, recipient); err != nil {
log.Printf("LMTP: Failed to process email for %s: %v", recipient, err)
return fmt.Errorf("failed to process email for %s: %w", recipient, err)
}
log.Printf("LMTP: Successfully processed email for %s", recipient)
}
return nil
}
// Reset is called when RSET command is received
func (s *Session) Reset() {
log.Printf("LMTP: Session reset")
s.from = ""
s.recipients = nil
}
// Logout is called when the session is closed
func (s *Session) Logout() error {
log.Printf("LMTP: Session logout")
return nil
}
// StartServer starts an LMTP server on the specified address
func StartServer(addr string, store storage.Storage, cfg *config.Config) error {
backend := NewBackend(store, cfg)
server := smtp.NewServer(backend)
server.Addr = addr
server.Domain = cfg.Email.Domain
server.AllowInsecureAuth = true
server.LMTP = true // Enable LMTP mode
log.Printf("Starting LMTP server on %s", addr)
// Create TCP listener explicitly
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to create LMTP listener: %w", err)
}
if err := server.Serve(listener); err != nil {
return fmt.Errorf("LMTP server error: %w", err)
}
return nil
}

View file

@ -0,0 +1,176 @@
// 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 receiver
import (
"encoding/json"
"fmt"
"io"
"log"
"regexp"
"strings"
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/pkg/analyzer"
)
// EmailReceiver handles incoming emails from the MTA
type EmailReceiver struct {
storage storage.Storage
config *config.Config
analyzer *analyzer.EmailAnalyzer
}
// NewEmailReceiver creates a new email receiver
func NewEmailReceiver(store storage.Storage, cfg *config.Config) *EmailReceiver {
return &EmailReceiver{
storage: store,
config: cfg,
analyzer: analyzer.NewEmailAnalyzer(cfg),
}
}
// ProcessEmail reads an email from the reader, analyzes it, and stores the results
func (r *EmailReceiver) ProcessEmail(emailData io.Reader, recipientEmail string) error {
// Read the entire email
rawEmail, err := io.ReadAll(emailData)
if err != nil {
return fmt.Errorf("failed to read email: %w", err)
}
return r.ProcessEmailBytes(rawEmail, recipientEmail)
}
// ProcessEmailBytes processes an email from a byte slice
func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string) error {
log.Printf("Received email for %s (%d bytes)", recipientEmail, len(rawEmail))
// Extract test ID from recipient email address
testID, err := r.extractTestID(recipientEmail)
if err != nil {
return fmt.Errorf("failed to extract test ID: %w", err)
}
log.Printf("Extracted test ID: %s", testID)
// Check if a report already exists for this test ID
reportExists, err := r.storage.ReportExists(testID)
if err != nil {
return fmt.Errorf("failed to check report existence: %w", err)
}
if reportExists {
log.Printf("Report already exists for test %s, skipping analysis", testID)
return nil
}
log.Printf("Analyzing email for test %s", testID)
// Analyze the email using the shared analyzer
result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID)
if err != nil {
return fmt.Errorf("failed to analyze email: %w", err)
}
log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score)
// Marshal report to JSON
reportJSON, err := json.Marshal(result.Report)
if err != nil {
return fmt.Errorf("failed to marshal report: %w", err)
}
// Store the report
if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil {
return fmt.Errorf("failed to store report: %w", err)
}
log.Printf("Report stored successfully for test %s", testID)
return nil
}
// extractTestID extracts the UUID from the test email address
// 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, "<>")
// Extract the local part (before @)
parts := strings.Split(email, "@")
if len(parts) != 2 {
return uuid.Nil, fmt.Errorf("invalid email format: %s", email)
}
localPart := parts[0]
// Remove the prefix (e.g., "test-")
if !strings.HasPrefix(localPart, r.config.Email.TestAddressPrefix) {
return uuid.Nil, fmt.Errorf("email does not have expected prefix: %s", email)
}
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
// Parse UUID
testID, err := uuid.Parse(uuidStr)
if err != nil {
return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr)
}
return testID, nil
}
// ExtractRecipientFromHeaders attempts to extract the recipient email from email headers
// This is useful when the email is piped and we need to determine the recipient
func ExtractRecipientFromHeaders(rawEmail []byte) (string, error) {
emailStr := string(rawEmail)
// Look for common recipient headers
headerPatterns := []string{
`(?i)^To:\s*(.+)$`,
`(?i)^X-Original-To:\s*(.+)$`,
`(?i)^Delivered-To:\s*(.+)$`,
`(?i)^Envelope-To:\s*(.+)$`,
}
for _, pattern := range headerPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(emailStr)
if len(matches) > 1 {
recipient := strings.TrimSpace(matches[1])
// Clean up the email address
recipient = strings.Trim(recipient, "<>")
// Take only the first email if there are multiple
if idx := strings.Index(recipient, ","); idx != -1 {
recipient = recipient[:idx]
}
if recipient != "" {
return recipient, nil
}
}
}
return "", fmt.Errorf("could not extract recipient from email headers")
}

View file

@ -0,0 +1,46 @@
// 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 storage
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Report represents the analysis report for a test
type Report struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
TestID uuid.UUID `gorm:"type:uuid;uniqueIndex;not null"` // The test ID extracted from email address
RawEmail []byte `gorm:"type:bytea;not null"` // Full raw email with headers
ReportJSON []byte `gorm:"type:bytea;not null"` // JSON-encoded report data
CreatedAt time.Time `gorm:"not null"`
}
// BeforeCreate is a GORM hook that generates a UUID before creating a report
func (r *Report) BeforeCreate(tx *gorm.DB) error {
if r.ID == uuid.Nil {
r.ID = uuid.New()
}
return nil
}

136
internal/storage/storage.go Normal file
View file

@ -0,0 +1,136 @@
// 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 storage
import (
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var (
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
)
// Storage interface defines operations for persisting and retrieving data
type Storage interface {
// Report operations
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)
DeleteOldReports(olderThan time.Time) (int64, error)
// Close closes the database connection
Close() error
}
// DBStorage implements Storage using GORM
type DBStorage struct {
db *gorm.DB
}
// NewStorage creates a new storage instance based on database type
func NewStorage(dbType, dsn string) (Storage, error) {
var dialector gorm.Dialector
switch dbType {
case "sqlite":
dialector = sqlite.Open(dsn)
case "postgres":
dialector = postgres.Open(dsn)
default:
return nil, fmt.Errorf("unsupported database type: %s", dbType)
}
db, err := gorm.Open(dialector, &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Auto-migrate the schema
if err := db.AutoMigrate(&Report{}); err != nil {
return nil, fmt.Errorf("failed to migrate database schema: %w", err)
}
return &DBStorage{db: db}, nil
}
// CreateReport creates a new report for a test
func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) {
dbReport := &Report{
TestID: testID,
RawEmail: rawEmail,
ReportJSON: reportJSON,
}
if err := s.db.Create(dbReport).Error; err != nil {
return nil, fmt.Errorf("failed to create report: %w", err)
}
return dbReport, nil
}
// ReportExists checks if a report exists for the given test ID
func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) {
var count int64
if err := s.db.Model(&Report{}).Where("test_id = ?", testID).Count(&count).Error; err != nil {
return false, fmt.Errorf("failed to check report existence: %w", err)
}
return count > 0, nil
}
// 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.First(&dbReport, "test_id = ?", testID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, ErrNotFound
}
return nil, nil, fmt.Errorf("failed to get report: %w", err)
}
return dbReport.ReportJSON, dbReport.RawEmail, 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{})
if result.Error != nil {
return 0, fmt.Errorf("failed to delete old reports: %w", result.Error)
}
return result.RowsAffected, nil
}
// Close closes the database connection
func (s *DBStorage) Close() error {
sqlDB, err := s.db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}

87
pkg/analyzer/analyzer.go Normal file
View file

@ -0,0 +1,87 @@
// 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 (
"bytes"
"fmt"
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
)
// EmailAnalyzer provides high-level email analysis functionality
// This is the main entry point for analyzing emails from both LMTP and CLI
type EmailAnalyzer struct {
generator *ReportGenerator
}
// NewEmailAnalyzer creates a new email analyzer with the given configuration
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
generator := NewReportGenerator(
cfg.Analysis.DNSTimeout,
cfg.Analysis.HTTPTimeout,
cfg.Analysis.RBLs,
)
return &EmailAnalyzer{
generator: generator,
}
}
// AnalysisResult contains the complete analysis result
type AnalysisResult struct {
Email *EmailMessage
Results *AnalysisResults
Report *api.Report
}
// AnalyzeEmailBytes performs complete email analysis from raw bytes
func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*AnalysisResult, error) {
// Parse the email
emailMsg, err := ParseEmail(bytes.NewReader(rawEmail))
if err != nil {
return nil, fmt.Errorf("failed to parse email: %w", err)
}
// Analyze the email
results := a.generator.AnalyzeEmail(emailMsg)
// Generate the report
report := a.generator.GenerateReport(testID, results)
return &AnalysisResult{
Email: emailMsg,
Results: results,
Report: report,
}, 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

@ -59,6 +59,14 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
}
}
// Parse ARC headers if not already parsed from Authentication-Results
if results.Arc == nil {
results.Arc = a.parseARCHeaders(email)
} else {
// Enhance the ARC result with chain information from raw headers
a.enhanceARCResult(email, results.Arc)
}
return results
}
@ -104,6 +112,20 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
results.Dmarc = a.parseDMARCResult(part)
}
}
// Parse BIMI
if strings.HasPrefix(part, "bimi=") {
if results.Bimi == nil {
results.Bimi = a.parseBIMIResult(part)
}
}
// Parse ARC
if strings.HasPrefix(part, "arc=") {
if results.Arc == nil {
results.Arc = a.parseARCResult(part)
}
}
}
}
@ -214,6 +236,201 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
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")
@ -288,224 +505,3 @@ func textprotoCanonical(s string) string {
}
return strings.Join(words, "-")
}
// GetAuthenticationScore calculates the authentication score (0-3 points)
func (a *AuthenticationAnalyzer) 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
}
// 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.Medium),
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.Medium),
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.Medium),
Advice: api.PtrTo("Implement DMARC policy for your domain"),
})
}
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.Info)
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.Critical)
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.Medium)
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.Low)
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.Medium)
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.Info)
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.High)
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.Medium)
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.Info)
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.High)
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.Medium)
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
}

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

@ -0,0 +1,846 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/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 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)
}
})
}
}
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)
}
})
}
}
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)
}
}
})
}
}
func TestGenerateAuthSPFCheck(t *testing.T) {
tests := []struct {
name string
spf *api.AuthResult
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "SPF pass",
spf: &api.AuthResult{
Result: api.AuthResultResultPass,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "SPF fail",
spf: &api.AuthResult{
Result: api.AuthResultResultFail,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
{
name: "SPF softfail",
spf: &api.AuthResult{
Result: api.AuthResultResultSoftfail,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.5,
},
{
name: "SPF neutral",
spf: &api.AuthResult{
Result: api.AuthResultResultNeutral,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.5,
},
}
analyzer := NewAuthenticationAnalyzer()
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.Authentication {
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
}
if check.Name != "SPF Record" {
t.Errorf("Name = %q, want %q", check.Name, "SPF Record")
}
})
}
}
func TestGenerateAuthDKIMCheck(t *testing.T) {
tests := []struct {
name string
dkim *api.AuthResult
index int
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "DKIM pass",
dkim: &api.AuthResult{
Result: api.AuthResultResultPass,
Domain: api.PtrTo("example.com"),
Selector: api.PtrTo("default"),
},
index: 0,
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "DKIM fail",
dkim: &api.AuthResult{
Result: api.AuthResultResultFail,
Domain: api.PtrTo("example.com"),
Selector: api.PtrTo("default"),
},
index: 0,
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
{
name: "DKIM none",
dkim: &api.AuthResult{
Result: api.AuthResultResultNone,
Domain: api.PtrTo("example.com"),
Selector: api.PtrTo("default"),
},
index: 0,
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.0,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateDKIMCheck(tt.dkim, tt.index)
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.Authentication {
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
}
if !strings.Contains(check.Name, "DKIM Signature") {
t.Errorf("Name should contain 'DKIM Signature', got %q", check.Name)
}
})
}
}
func TestGenerateAuthDMARCCheck(t *testing.T) {
tests := []struct {
name string
dmarc *api.AuthResult
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "DMARC pass",
dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "DMARC fail",
dmarc: &api.AuthResult{
Result: api.AuthResultResultFail,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewAuthenticationAnalyzer()
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.Authentication {
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
}
if check.Name != "DMARC Policy" {
t.Errorf("Name = %q, want %q", check.Name, "DMARC Policy")
}
})
}
}
func TestGenerateAuthBIMICheck(t *testing.T) {
tests := []struct {
name string
bimi *api.AuthResult
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "BIMI pass",
bimi: &api.AuthResult{
Result: api.AuthResultResultPass,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.0, // BIMI doesn't contribute to score
},
{
name: "BIMI fail",
bimi: &api.AuthResult{
Result: api.AuthResultResultFail,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0.0,
},
{
name: "BIMI none",
bimi: &api.AuthResult{
Result: api.AuthResultResultNone,
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0.0,
},
}
analyzer := NewAuthenticationAnalyzer()
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.Authentication {
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
}
if check.Name != "BIMI (Brand Indicators)" {
t.Errorf("Name = %q, want %q", check.Name, "BIMI (Brand Indicators)")
}
// BIMI should always have score of 0.0 (branding feature)
if check.Score != 0.0 {
t.Error("BIMI should not contribute to deliverability score")
}
})
}
}
func TestGetAuthenticationScore(t *testing.T) {
tests := []struct {
name string
results *api.AuthenticationResults
expectedScore float32
}{
{
name: "Perfect authentication (SPF + DKIM + DMARC)",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
},
},
expectedScore: 3.0,
},
{
name: "SPF and DKIM only",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
},
expectedScore: 2.0,
},
{
name: "SPF fail, DKIM pass",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultFail,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
},
expectedScore: 1.0,
},
{
name: "SPF softfail",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultSoftfail,
},
},
expectedScore: 0.5,
},
{
name: "No authentication",
results: &api.AuthenticationResults{},
expectedScore: 0.0,
},
{
name: "BIMI doesn't affect score",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Bimi: &api.AuthResult{
Result: api.AuthResultResultPass,
},
},
expectedScore: 1.0, // Only SPF counted, not BIMI
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := scorer.GetAuthenticationScore(tt.results)
if score != tt.expectedScore {
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
}
})
}
}
func TestGenerateAuthenticationChecks(t *testing.T) {
tests := []struct {
name string
results *api.AuthenticationResults
expectedChecks int
}{
{
name: "All authentication methods present",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Bimi: &api.AuthResult{
Result: api.AuthResultResultPass,
},
},
expectedChecks: 4, // SPF, DKIM, DMARC, BIMI
},
{
name: "Without BIMI",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
},
},
expectedChecks: 3, // SPF, DKIM, DMARC
},
{
name: "No authentication results",
results: &api.AuthenticationResults{},
expectedChecks: 3, // SPF, DKIM, DMARC warnings for missing
},
{
name: "With ARC",
results: &api.AuthenticationResults{
Spf: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{
Result: api.AuthResultResultPass,
},
Arc: &api.ARCResult{
Result: api.ARCResultResultPass,
ChainLength: api.PtrTo(2),
ChainValid: api.PtrTo(true),
},
},
expectedChecks: 4, // SPF, DKIM, DMARC, ARC
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateAuthenticationChecks(tt.results)
if len(checks) != tt.expectedChecks {
t.Errorf("Got %d checks, want %d", len(checks), tt.expectedChecks)
}
// Verify all checks have the Authentication category
for _, check := range checks {
if check.Category != api.Authentication {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Authentication)
}
}
})
}
}
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)
}
})
}
}
func TestGenerateARCCheck(t *testing.T) {
tests := []struct {
name string
arc *api.ARCResult
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "ARC pass",
arc: &api.ARCResult{
Result: api.ARCResultResultPass,
ChainLength: api.PtrTo(2),
ChainValid: api.PtrTo(true),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.0, // ARC doesn't contribute to score
},
{
name: "ARC fail",
arc: &api.ARCResult{
Result: api.ARCResultResultFail,
ChainLength: api.PtrTo(1),
ChainValid: api.PtrTo(false),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.0,
},
{
name: "ARC none",
arc: &api.ARCResult{
Result: api.ARCResultResultNone,
ChainLength: api.PtrTo(0),
ChainValid: api.PtrTo(true),
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0.0,
},
}
analyzer := NewAuthenticationAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateARCCheck(tt.arc)
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.Authentication {
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
}
if !strings.Contains(check.Name, "ARC") {
t.Errorf("Name should contain 'ARC', got %q", check.Name)
}
})
}
}

View file

@ -507,7 +507,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api
if !results.HTMLValid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.Medium)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "HTML structure is invalid"
if len(results.HTMLErrors) > 0 {
details := strings.Join(results.HTMLErrors, "; ")
@ -517,7 +517,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api
} else {
check.Status = api.CheckStatusPass
check.Score = 0.2
check.Severity = api.PtrTo(api.Info)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "HTML structure is valid"
check.Advice = api.PtrTo("Your HTML is well-formed")
}
@ -552,7 +552,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
if brokenLinks > 0 {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.High)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks)
check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability")
details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks)
@ -560,7 +560,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
} else if warningLinks > 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.3
check.Severity = api.PtrTo(api.Low)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks)
check.Advice = api.PtrTo("Review links that could not be verified")
details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks)
@ -568,7 +568,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
} else {
check.Status = api.CheckStatusPass
check.Score = 0.4
check.Severity = api.PtrTo(api.Info)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links))
check.Advice = api.PtrTo("Your links are working properly")
}
@ -601,7 +601,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
if noAltCount == len(results.Images) {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.Medium)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "No images have alt attributes"
check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability")
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
@ -609,7 +609,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
} else if noAltCount > 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.2
check.Severity = api.PtrTo(api.Low)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount)
check.Advice = api.PtrTo("Add alt text to all images for better accessibility")
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
@ -617,7 +617,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.Info)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All images have alt attributes"
check.Advice = api.PtrTo("Your images are properly tagged for accessibility")
}
@ -636,13 +636,13 @@ func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.
if !results.HasUnsubscribe {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.Low)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "No unsubscribe link found"
check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)")
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.Info)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks))
check.Advice = api.PtrTo("Your email includes an unsubscribe option")
}
@ -662,7 +662,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults)
if consistency < 0.3 {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.Low)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "Plain text and HTML versions differ significantly"
check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content")
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
@ -670,7 +670,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults)
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.Info)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Plain text and HTML versions are consistent"
check.Advice = api.PtrTo("Your multipart email is well-structured")
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
@ -693,7 +693,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
if ratio > 10.0 {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.Medium)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Email is excessively image-heavy"
check.Advice = api.PtrTo("Reduce the number of images relative to text content")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
@ -701,7 +701,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
} else if ratio > 5.0 {
check.Status = api.CheckStatusWarn
check.Score = 0.2
check.Severity = api.PtrTo(api.Low)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "Email has high image-to-text ratio"
check.Advice = api.PtrTo("Consider adding more text content relative to images")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
@ -709,7 +709,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.Info)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Image-to-text ratio is reasonable"
check.Advice = api.PtrTo("Your content has a good balance of images and text")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
@ -730,7 +730,7 @@ func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) ap
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.Medium)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count)
check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails")

View file

@ -58,6 +58,7 @@ type DNSResults struct {
SPFRecord *SPFRecord
DKIMRecords []DKIMRecord
DMARCRecord *DMARCRecord
BIMIRecord *BIMIRecord
Errors []string
}
@ -93,6 +94,17 @@ type DMARCRecord struct {
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) *DNSResults {
// Extract domain from From address
@ -128,6 +140,9 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
// Check DMARC record
results.DMARCRecord = d.checkDMARCRecord(domain)
// Check BIMI record (using default selector)
results.BIMIRecord = d.checkBIMIRecord(domain, "default")
return results
}
@ -395,6 +410,89 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool {
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
@ -421,6 +519,11 @@ func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
}
// BIMI record check (optional)
if results.BIMIRecord != nil {
checks = append(checks, d.generateBIMICheck(results.BIMIRecord))
}
return checks
}
@ -434,7 +537,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.Critical)
check.Severity = api.PtrTo(api.CheckSeverityCritical)
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
check.Message = results.MXRecords[0].Error
@ -445,7 +548,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Severity = api.PtrTo(api.Info)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
// Add details about MX records
@ -474,14 +577,14 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = spf.Error
check.Severity = api.PtrTo(api.High)
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.Medium)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
check.Details = &spf.Record
}
@ -489,7 +592,7 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "Valid SPF record found"
check.Severity = api.PtrTo(api.Info)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &spf.Record
check.Advice = api.PtrTo("Your SPF record is properly configured")
}
@ -508,7 +611,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
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.High)
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
@ -516,7 +619,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "Valid DKIM record found"
check.Severity = api.PtrTo(api.Info)
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")
@ -536,13 +639,13 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = dmarc.Error
check.Severity = api.PtrTo(api.High)
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.Info)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &dmarc.Record
// Provide advice based on policy
@ -564,3 +667,53 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
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

@ -631,3 +631,190 @@ func TestAnalyzeDNS_NoDomain(t *testing.T) {
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

@ -279,7 +279,7 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check {
Status: api.CheckStatusWarn,
Score: 1.0,
Message: "No public IP addresses found to check",
Severity: api.PtrTo(api.Low),
Severity: api.PtrTo(api.CheckSeverityLow),
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
})
return checks
@ -316,22 +316,22 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
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.Info)
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.Medium)
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.High)
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.Critical)
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")
}
@ -357,15 +357,15 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check {
// Determine severity based on which RBL
if strings.Contains(rblCheck.RBL, "spamhaus") {
check.Severity = api.PtrTo(api.Critical)
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.High)
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.High)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
check.Advice = &advice
}

View file

@ -419,7 +419,7 @@ func TestGenerateListingCheck(t *testing.T) {
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.Critical,
expectedSeverity: api.CheckSeverityCritical,
},
{
name: "SpamCop listing",
@ -430,7 +430,7 @@ func TestGenerateListingCheck(t *testing.T) {
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.High,
expectedSeverity: api.CheckSeverityHigh,
},
{
name: "Other RBL listing",
@ -441,7 +441,7 @@ func TestGenerateListingCheck(t *testing.T) {
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.High,
expectedSeverity: api.CheckSeverityHigh,
},
}

View file

@ -72,8 +72,7 @@ func (s *DeliverabilityScorer) CalculateScore(
}
// Calculate individual scores
authAnalyzer := NewAuthenticationAnalyzer()
result.AuthScore = authAnalyzer.GetAuthenticationScore(authResults)
result.AuthScore = s.GetAuthenticationScore(authResults)
spamAnalyzer := NewSpamAssassinAnalyzer()
result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult)
@ -351,13 +350,13 @@ func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage)
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 0.4
check.Severity = api.PtrTo(api.Info)
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.Critical)
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, ", "))
@ -386,13 +385,13 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.Info)
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.Low)
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, ", "))
@ -400,7 +399,7 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa
} else {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.Medium)
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")
}
@ -420,20 +419,20 @@ func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.C
if messageID == "" {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.High)
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.Medium)
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.Info)
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
@ -452,13 +451,13 @@ func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) a
if len(email.Parts) == 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.Low)
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.Info)
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")
@ -504,3 +503,43 @@ func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string {
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

@ -86,7 +86,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss
// Parse X-Spam-Report header for detailed test results
if reportHeader, ok := headers["X-Spam-Report"]; ok {
result.RawReport = reportHeader
result.RawReport = strings.Replace(reportHeader, " * ", "\n * ", -1)
a.parseSpamReport(reportHeader, result)
}
@ -140,20 +140,25 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass
// Format varies, but typically:
// * 1.5 TEST_NAME Description of test
// * 0.0 TEST_NAME2 Description
// 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) {
// Split by lines
lines := strings.Split(report, "\n")
// 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
testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
// 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+(.*)$`)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
continue
}
matches := testRe.FindStringSubmatch(line)
// Try to match as a test line
matches := testRe.FindStringSubmatch(segment)
if len(matches) > 3 {
testName := matches[2]
score, _ := strconv.ParseFloat(matches[1], 64)
@ -217,7 +222,7 @@ func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinRe
Status: api.CheckStatusWarn,
Score: 0.0,
Message: "No SpamAssassin headers found",
Severity: api.PtrTo(api.Medium),
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
})
return checks
@ -260,27 +265,27 @@ func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult)
if score <= 0 {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.Info)
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.Info)
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.Medium)
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.High)
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.Critical)
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")
}
@ -307,10 +312,10 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec
// Negative indicator (increases spam score)
if detail.Score > 2.0 {
check.Status = api.CheckStatusFail
check.Severity = api.PtrTo(api.High)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
} else {
check.Status = api.CheckStatusWarn
check.Severity = api.PtrTo(api.Medium)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
}
check.Score = 0.0
check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
@ -320,7 +325,7 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec
// Positive indicator (decreases spam score)
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Severity = api.PtrTo(api.Info)
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

View file

@ -22,6 +22,7 @@
package analyzer
import (
"bytes"
"net/mail"
"strings"
"testing"
@ -480,6 +481,176 @@ func TestGenerateTestCheck(t *testing.T) {
}
}
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,
RCVD_IN_VALIDITY_RPBL_BLOCKED,RCVD_IN_VALIDITY_SAFE_BLOCKED,
SPF_HELO_NONE,SPF_PASS autolearn=disabled version=4.0.1
X-Spam-Level:
X-Spam-Report:
* 0.0 RCVD_IN_VALIDITY_SAFE_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
* to Validity was blocked. See
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
* more information.
* [80.67.179.207 listed in sa-accredit.habeas.com]
* 0.0 RCVD_IN_VALIDITY_RPBL_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
* to Validity was blocked. See
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
* more information.
* [80.67.179.207 listed in bl.score.senderscore.com]
* 0.0 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED RBL: ADMINISTRATOR NOTICE: The
* query to Validity was blocked. See
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
* more information.
* [80.67.179.207 listed in sa-trusted.bondedsender.org]
* -0.0 SPF_PASS SPF: sender matches SPF record
* 0.0 SPF_HELO_NONE SPF: HELO does not publish an SPF Record
* -0.1 DKIM_VALID Message has at least one valid DKIM or DK signature
* 0.1 DKIM_SIGNED Message has a DKIM or DK signature, not necessarily
* valid
* -0.1 DKIM_VALID_AU Message has a valid DKIM or DK signature from author's
* domain
Date: Sun, 19 Oct 2025 08:37:30 +0000
Message-ID: <aPSjR57mUnCAt7sp@happydomain.org>
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
BODY`
// TestAnalyzeRealEmailExample tests the analyzer with the real example email file
func TestAnalyzeRealEmailExample(t *testing.T) {
// Parse the email using the standard net/mail package
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithSpamassassinHeader))
if err != nil {
t.Fatalf("Failed to parse email: %v", err)
}
// Create analyzer and analyze SpamAssassin headers
analyzer := NewSpamAssassinAnalyzer()
result := analyzer.AnalyzeSpamAssassin(email)
// Validate that we got a result
if result == nil {
t.Fatal("Expected SpamAssassin result, got nil")
}
// Validate IsSpam flag (should be false for this email)
if result.IsSpam {
t.Error("IsSpam should be false for real_example.eml")
}
// Validate score (should be -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)
expectedRequired := 5.0
if result.RequiredScore != expectedRequired {
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired)
}
// Validate 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 {
t.Error("Expected tests to be extracted, got none")