Compare commits

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

14 commits

67 changed files with 9658 additions and 56 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

99
Dockerfile Normal file
View file

@ -0,0 +1,99 @@
# 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
# 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"]

213
README.md Normal file
View file

@ -0,0 +1,213 @@
# happyDeliver
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, 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
**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

View file

@ -22,31 +22,39 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"git.happydns.org/happyDeliver/internal/app"
"git.happydns.org/happyDeliver/internal/config"
)
func main() {
fmt.Println("Mail Tester - Email Deliverability Testing Platform")
fmt.Println("Version: 0.1.0-dev")
const version = "0.1.0-dev"
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
func main() {
fmt.Println("happyDeliver - Email Deliverability Testing Platform")
fmt.Printf("Version: %s\n", version)
cfg, err := config.ConsolidateConfig()
if err != nil {
log.Fatal(err.Error())
}
command := os.Args[1]
command := flag.Arg(0)
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()
@ -55,8 +63,10 @@ func main() {
}
func printUsage() {
fmt.Println("\nUsage:")
fmt.Println(" mailtester server - Start the API server")
fmt.Println(" mailtester analyze - Start the email analyzer (MDA mode)")
fmt.Println(" mailtester version - Print version information")
fmt.Println("\nCommand availables:")
fmt.Println(" happyDeliver server - Start the API server")
fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
fmt.Println(" happyDeliver version - Print version information")
fmt.Println("")
flag.Usage()
}

40
docker-compose.yml Normal file
View file

@ -0,0 +1,40 @@
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
# Optional: Override config
# - ./custom-config.yaml:/etc/happydeliver/config.yaml
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"

31
go.mod
View file

@ -3,20 +3,24 @@ module git.happydns.org/happyDeliver
go 1.24.6
require (
github.com/getkin/kin-openapi v0.132.0
github.com/emersion/go-smtp v0.24.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.45.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/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/getkin/kin-openapi v0.132.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
@ -25,12 +29,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 +51,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 +59,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

63
go.sum
View file

@ -1,7 +1,3 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
@ -17,6 +13,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,11 +68,22 @@ 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=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@ -88,6 +99,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 +139,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=
@ -136,13 +149,13 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/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 +175,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 +187,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.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
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 +209,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 +255,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=

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)
}

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/analyzer"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
)
// 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
}

50
internal/config/cli.go Normal file
View file

@ -0,0 +1,50 @@
// 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 config
import (
"flag"
)
// declareFlags registers flags for the structure Options.
func declareFlags(o *Config) {
flag.StringVar(&o.DevProxy, "dev", o.DevProxy, "Proxify traffic to this host for static assets")
flag.StringVar(&o.Bind, "bind", o.Bind, "Bind port/socket")
flag.StringVar(&o.Database.Type, "database-type", o.Database.Type, "Select the database type between sqlite, postgres")
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
}
// parseCLI parse the flags and treats extra args as configuration filename.
func parseCLI(o *Config) error {
flag.Parse()
return nil
}

180
internal/config/config.go Normal file
View file

@ -0,0 +1,180 @@
// 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 config
import (
"flag"
"fmt"
"log"
"os"
"path"
"strings"
"time"
openapi_types "github.com/oapi-codegen/runtime/types"
)
// Config represents the application configuration
type Config struct {
DevProxy string
Bind string
Database DatabaseConfig
Email EmailConfig
Analysis AnalysisConfig
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
}
// DatabaseConfig contains database connection settings
type DatabaseConfig struct {
Type string
DSN string
}
// EmailConfig contains email domain and routing settings
type EmailConfig struct {
Domain string
TestAddressPrefix string
LMTPAddr string
}
// AnalysisConfig contains timeout and behavior settings for email analysis
type AnalysisConfig struct {
DNSTimeout time.Duration
HTTPTimeout time.Duration
RBLs []string
}
// DefaultConfig returns a configuration with sensible defaults
func DefaultConfig() *Config {
return &Config{
DevProxy: "",
Bind: ":8081",
ReportRetention: 0, // Keep reports forever by default
Database: DatabaseConfig{
Type: "sqlite",
DSN: "happydeliver.db",
},
Email: EmailConfig{
Domain: "happydeliver.local",
TestAddressPrefix: "test-",
LMTPAddr: "127.0.0.1:2525",
},
Analysis: AnalysisConfig{
DNSTimeout: 5 * time.Second,
HTTPTimeout: 10 * time.Second,
RBLs: []string{},
},
}
}
// ConsolidateConfig fills an Options struct by reading configuration from
// config files, environment, then command line.
//
// Should be called only one time.
func ConsolidateConfig() (opts *Config, err error) {
// Define defaults options
opts = DefaultConfig()
declareFlags(opts)
// Establish a list of possible configuration file locations
configLocations := []string{
"happydeliver.conf",
}
if home, err := os.UserConfigDir(); err == nil {
configLocations = append(
configLocations,
path.Join(home, "happydeliver", "happydeliver.conf"),
path.Join(home, "happydomain", "happydeliver.conf"),
)
}
configLocations = append(configLocations, path.Join("etc", "happydeliver.conf"))
// If config file exists, read configuration from it
for _, filename := range configLocations {
if _, e := os.Stat(filename); !os.IsNotExist(e) {
log.Printf("Loading configuration from %s\n", filename)
err = parseFile(opts, filename)
if err != nil {
return
}
break
}
}
// Then, overwrite that by what is present in the environment
err = parseEnvironmentVariables(opts)
if err != nil {
return
}
// Finaly, command line takes precedence
err = parseCLI(opts)
if err != nil {
return
}
return
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
if c.Email.Domain == "" {
return fmt.Errorf("email domain cannot be empty")
}
if _, err := openapi_types.Email(fmt.Sprintf("%s1234-5678-9090@%s", c.Email.TestAddressPrefix, c.Email.Domain)).MarshalJSON(); err != nil {
return fmt.Errorf("invalid email domain: %w", err)
}
if c.Database.Type != "sqlite" && c.Database.Type != "postgres" {
return fmt.Errorf("unsupported database type: %s", c.Database.Type)
}
if c.Database.DSN == "" {
return fmt.Errorf("database DSN cannot be empty")
}
return nil
}
// parseLine treats a config line and place the read value in the variable
// declared to the corresponding flag.
func parseLine(o *Config, line string) (err error) {
fields := strings.SplitN(line, "=", 2)
orig_key := strings.TrimSpace(fields[0])
value := strings.TrimSpace(fields[1])
if len(value) == 0 {
return
}
key := strings.TrimPrefix(strings.TrimPrefix(orig_key, "HAPPYDELIVER_"), "HAPPYDOMAIN_")
key = strings.Replace(key, "_", "-", -1)
key = strings.ToLower(key)
err = flag.Set(key, value)
return
}

45
internal/config/custom.go Normal file
View file

@ -0,0 +1,45 @@
// 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 config
import (
"fmt"
"strings"
)
type StringArray struct {
Array *[]string
}
func (i *StringArray) String() string {
if i.Array == nil {
return ""
}
return fmt.Sprintf("%v", *i.Array)
}
func (i *StringArray) Set(value string) error {
*i.Array = append(*i.Array, strings.Split(value, ",")...)
return nil
}

42
internal/config/env.go Normal file
View file

@ -0,0 +1,42 @@
// 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 config
import (
"fmt"
"os"
"strings"
)
// parseEnvironmentVariables analyzes all the environment variables to find
// each one starting by HAPPYDELIVER_
func parseEnvironmentVariables(o *Config) (err error) {
for _, line := range os.Environ() {
if strings.HasPrefix(line, "HAPPYDELIVER_") || strings.HasPrefix(line, "HAPPYDOMAIN_") {
err := parseLine(o, line)
if err != nil {
return fmt.Errorf("error in environment (%q): %w", line, err)
}
}
}
return
}

54
internal/config/file.go Normal file
View file

@ -0,0 +1,54 @@
// 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 config
import (
"bufio"
"fmt"
"os"
"strings"
)
// parseFile opens the file at the given filename path, then treat each line
// not starting with '#' as a configuration statement.
func parseFile(o *Config, filename string) error {
fp, err := os.Open(filename)
if err != nil {
return err
}
defer fp.Close()
scanner := bufio.NewScanner(fp)
n := 0
for scanner.Scan() {
n += 1
line := strings.TrimSpace(scanner.Text())
if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
err := parseLine(o, line)
if err != nil {
return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err)
}
}
}
return nil
}

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/analyzer"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/storage"
)
// 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()
}

7
renovate.json Normal file
View file

@ -0,0 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>iac/renovate-config",
"local>iac/renovate-config//automerge-common"
]
}

26
web/.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# OpenAPI
src/lib/api

1
web/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

9
web/.prettierignore Normal file
View file

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

13
web/.prettierrc Normal file
View file

@ -0,0 +1,13 @@
{
"tabWidth": 4,
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

43
web/assets.go Normal file
View file

@ -0,0 +1,43 @@
// 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 web
import (
"embed"
"io/fs"
"log"
"net/http"
)
//go:embed all:build
var _assets embed.FS
var Assets http.FileSystem
func init() {
sub, err := fs.Sub(_assets, "build")
if err != nil {
log.Fatal("Unable to cd to build/ directory:", err)
}
Assets = http.FS(sub)
}

41
web/eslint.config.js Normal file
View file

@ -0,0 +1,41 @@
import prettier from "eslint-config-prettier";
import { fileURLToPath } from "node:url";
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import svelte from "eslint-plugin-svelte";
import { defineConfig } from "eslint/config";
import globals from "globals";
import ts from "typescript-eslint";
import svelteConfig from "./svelte.config.js";
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
"no-undef": "off",
},
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser,
svelteConfig,
},
},
},
);

12
web/openapi-ts.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from "@hey-api/openapi-ts";
export default defineConfig({
input: "../api/openapi.yaml",
output: "src/lib/api",
plugins: [
{
name: "@hey-api/client-fetch",
runtimeConfigPath: "./src/lib/hey-api.ts",
},
],
});

5312
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
web/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "happyDeliver",
"version": "0.1.0",
"type": "module",
"license": "AGPL-3.0-or-later",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"test": "vitest",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"generate:api": "openapi-ts"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@hey-api/openapi-ts": "0.85.2",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/node": "^22",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.10",
"vitest": "^3.2.4"
},
"dependencies": {
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1"
}
}

171
web/routes.go Normal file
View file

@ -0,0 +1,171 @@
// 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 web
import (
"encoding/json"
"io"
"io/fs"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path"
"strings"
"text/template"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDeliver/internal/config"
)
var (
indexTpl *template.Template
CustomHeadHTML = ""
)
func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
appConfig := map[string]interface{}{}
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
log.Println("Unable to generate JSON config to inject in web application")
} else {
CustomHeadHTML += `<script id="app-config" type="application/json">` + string(appcfg) + `</script>`
}
if cfg.DevProxy != "" {
router.GET("/.svelte-kit/*_", serveOrReverse("", cfg))
router.GET("/node_modules/*_", serveOrReverse("", cfg))
router.GET("/@vite/*_", serveOrReverse("", cfg))
router.GET("/@id/*_", serveOrReverse("", cfg))
router.GET("/@fs/*_", serveOrReverse("", cfg))
router.GET("/src/*_", serveOrReverse("", cfg))
router.GET("/home/*_", serveOrReverse("", cfg))
}
router.GET("/_app/", serveOrReverse("", cfg))
router.GET("/_app/immutable/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/", serveOrReverse("/", cfg))
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/img/*path", serveOrReverse("", cfg))
router.NoRoute(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api") || strings.Contains(c.Request.Header.Get("Accept"), "application/json") {
c.JSON(404, gin.H{"code": "PAGE_NOT_FOUND", "errmsg": "Page not found"})
} else {
serveOrReverse("/", cfg)(c)
}
})
}
func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if cfg.DevProxy != "" {
// Forward to the Svelte dev proxy
return func(c *gin.Context) {
if u, err := url.Parse(cfg.DevProxy); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else {
if forced_url != "" {
u.Path = path.Join(u.Path, forced_url)
} else {
u.Path = path.Join(u.Path, c.Request.URL.Path)
}
u.RawQuery = c.Request.URL.RawQuery
if r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else if resp, err := http.DefaultClient.Do(r); err != nil {
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
} else {
defer resp.Body.Close()
if u.Path != "/" || resp.StatusCode != 200 {
for key := range resp.Header {
c.Writer.Header().Add(key, resp.Header.Get(key))
}
c.Writer.WriteHeader(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
} else {
for key := range resp.Header {
if strings.ToLower(key) != "content-length" {
c.Writer.Header().Add(key, resp.Header.Get(key))
}
}
v, _ := ioutil.ReadAll(resp.Body)
v2 := strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
"Head": CustomHeadHTML,
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
}
}
}
}
} else if Assets == nil {
return func(c *gin.Context) {
c.String(http.StatusNotFound, "404 Page not found - interface not embedded in binary, please compile with -tags web")
}
} else if forced_url == "/" {
// Serve altered index.html
return func(c *gin.Context) {
if indexTpl == nil {
// Create template from file
f, _ := Assets.Open("index.html")
v, _ := ioutil.ReadAll(f)
v2 := strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
}
// Serve template
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
"Head": CustomHeadHTML,
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
}
} else if forced_url != "" {
// Serve forced_url
return func(c *gin.Context) {
c.FileFromFS(forced_url, Assets)
}
} else {
// Serve requested file
return func(c *gin.Context) {
if _, err := fs.Stat(_assets, path.Join("build", c.Request.URL.Path)); os.IsNotExist(err) {
c.FileFromFS("/404.html", Assets)
} else {
c.FileFromFS(c.Request.URL.Path, Assets)
}
}
}
}

152
web/src/app.css Normal file
View file

@ -0,0 +1,152 @@
:root {
--bs-primary: #1cb487;
--bs-primary-rgb: 28, 180, 135;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.fade-in {
animation: fadeIn 0.6s ease-out;
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
.spin {
animation: spin 1s linear infinite;
}
/* Score styling */
.score-excellent {
color: #198754;
}
.score-good {
color: #20c997;
}
.score-warning {
color: #ffc107;
}
.score-poor {
color: #fd7e14;
}
.score-bad {
color: #dc3545;
}
/* Custom card styling */
.card {
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
/* Check status badges */
.check-status {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.check-pass {
background-color: #d1e7dd;
color: #0f5132;
}
.check-fail {
background-color: #f8d7da;
color: #842029;
}
.check-warn {
background-color: #fff3cd;
color: #664d03;
}
.check-info {
background-color: #cfe2ff;
color: #084298;
}
/* Clipboard button */
.clipboard-btn {
cursor: pointer;
transition: all 0.2s ease;
}
.clipboard-btn:hover {
transform: scale(1.1);
}
.clipboard-btn:active {
transform: scale(0.95);
}
/* Progress bar animation */
.progress-bar {
transition: width 0.6s ease;
}
/* Hero section */
.hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
/* Feature icons */
.feature-icon {
width: 4rem;
height: 4rem;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin-bottom: 1rem;
}

13
web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
web/src/app.html Normal file
View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

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

View file

@ -0,0 +1,46 @@
<script lang="ts">
interface Props {
email: string;
}
let { email }: Props = $props();
let copied = $state(false);
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(email);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}
</script>
<div class="bg-light rounded p-4">
<div class="d-flex align-items-center justify-content-center gap-3">
<code class="fs-5 text-primary fw-bold">{email}</code>
<button
class="btn btn-sm btn-outline-primary clipboard-btn"
onclick={copyToClipboard}
title="Copy to clipboard"
>
<i class={copied ? "bi bi-check2" : "bi bi-clipboard"}></i>
</button>
</div>
{#if copied}
<small class="text-success d-block mt-2">
<i class="bi bi-check2"></i> Copied to clipboard!
</small>
{/if}
</div>
<style>
.clipboard-btn {
transition: all 0.2s ease;
}
.clipboard-btn:hover {
transform: scale(1.1);
}
</style>

View file

@ -0,0 +1,33 @@
<script lang="ts">
interface Props {
icon: string;
title: string;
description: string;
variant?: "primary" | "success" | "warning" | "danger" | "info" | "secondary";
}
let { icon, title, description, variant = "primary" }: Props = $props();
</script>
<div class="card h-100 text-center p-4">
<div class="feature-icon bg-{variant} bg-opacity-10 text-{variant} mx-auto">
<i class="bi {icon}"></i>
</div>
<h5 class="fw-bold">{title}</h5>
<p class="text-muted small mb-0">
{description}
</p>
</div>
<style>
.feature-icon {
width: 4rem;
height: 4rem;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin-bottom: 1rem;
}
</style>

View file

@ -0,0 +1,17 @@
<script lang="ts">
interface Props {
step: number;
title: string;
description: string;
}
let { step, title, description }: Props = $props();
</script>
<div class="card h-100 text-center p-4">
<div class="display-1 text-primary fw-bold opacity-25">{step}</div>
<h5 class="fw-bold mt-3">{title}</h5>
<p class="text-muted mb-0">
{description}
</p>
</div>

View file

@ -0,0 +1,108 @@
<script lang="ts">
import type { Test } from "$lib/api/types.gen";
import EmailAddressDisplay from "./EmailAddressDisplay.svelte";
interface Props {
test: Test;
}
let { test }: Props = $props();
</script>
<div class="row justify-content-center">
<div class="col-lg-8 fade-in">
<div class="card shadow-lg">
<div class="card-body p-5 text-center">
<div class="pulse mb-4">
<i class="bi bi-envelope-paper display-1 text-primary"></i>
</div>
<h2 class="fw-bold mb-3">Waiting for Your Email</h2>
<p class="text-muted mb-4">Send your test email to the address below:</p>
<div class="mb-4">
<EmailAddressDisplay email={test.email} />
</div>
<div class="alert alert-info mb-4" role="alert">
<i class="bi bi-lightbulb me-2"></i>
<strong>Tip:</strong> Send an email that represents your actual use case (newsletters,
transactional emails, etc.) for the most accurate results.
</div>
<div class="d-flex align-items-center justify-content-center gap-2 text-muted">
<div class="spinner-border spinner-border-sm" role="status"></div>
<small>Checking for email every 3 seconds...</small>
</div>
</div>
</div>
<!-- Instructions Card -->
<div class="card mt-4">
<div class="card-body">
<h5 class="fw-bold mb-3">
<i class="bi bi-info-circle me-2"></i>What we'll check:
</h5>
<div class="row g-3">
<div class="col-md-6">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> SPF, DKIM, DMARC
</li>
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> DNS Records
</li>
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> SpamAssassin Score
</li>
</ul>
</div>
<div class="col-md-6">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> Blacklist Status
</li>
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> Content Quality
</li>
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> Header Validation
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>

View file

@ -0,0 +1,71 @@
<script lang="ts">
import type { ScoreSummary } from "$lib/api/types.gen";
interface Props {
score: number;
summary?: ScoreSummary;
}
let { score, summary }: Props = $props();
function getScoreClass(score: number): string {
if (score >= 9) return "score-excellent";
if (score >= 7) return "score-good";
if (score >= 5) return "score-warning";
if (score >= 3) return "score-poor";
return "score-bad";
}
function getScoreLabel(score: number): string {
if (score >= 9) return "Excellent";
if (score >= 7) return "Good";
if (score >= 5) return "Fair";
if (score >= 3) return "Poor";
return "Critical";
}
</script>
<div class="card shadow-lg bg-white">
<div class="card-body p-5 text-center">
<h1 class="display-1 fw-bold mb-3 {getScoreClass(score)}">
{score.toFixed(1)}/10
</h1>
<h3 class="fw-bold mb-2">{getScoreLabel(score)}</h3>
<p class="text-muted mb-4">Overall Deliverability Score</p>
{#if summary}
<div class="row g-3 text-start">
<div class="col-md-6 col-lg">
<div class="p-3 bg-light rounded">
<small class="text-muted d-block">Authentication</small>
<strong class="fs-5">{summary.authentication_score.toFixed(1)}/3</strong>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-3 bg-light rounded">
<small class="text-muted d-block">Spam Score</small>
<strong class="fs-5">{summary.spam_score.toFixed(1)}/2</strong>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-3 bg-light rounded">
<small class="text-muted d-block">Blacklists</small>
<strong class="fs-5">{summary.blacklist_score.toFixed(1)}/2</strong>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-3 bg-light rounded">
<small class="text-muted d-block">Content</small>
<strong class="fs-5">{summary.content_score.toFixed(1)}/2</strong>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-3 bg-light rounded">
<small class="text-muted d-block">Headers</small>
<strong class="fs-5">{summary.header_score.toFixed(1)}/1</strong>
</div>
</div>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import type { SpamAssassinResult } from "$lib/api/types.gen";
interface Props {
spamassassin: SpamAssassinResult;
}
let { spamassassin }: Props = $props();
</script>
<div class="card">
<div class="card-header bg-warning bg-opacity-10">
<h5 class="mb-0 fw-bold">
<i class="bi bi-bug me-2"></i>SpamAssassin Analysis
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<strong>Score:</strong>
<span class={spamassassin.is_spam ? "text-danger" : "text-success"}>
{spamassassin.score.toFixed(2)} / {spamassassin.required_score.toFixed(1)}
</span>
</div>
<div class="col-md-6">
<strong>Classified as:</strong>
<span class="badge {spamassassin.is_spam ? 'bg-danger' : 'bg-success'} ms-2">
{spamassassin.is_spam ? "SPAM" : "HAM"}
</span>
</div>
</div>
{#if spamassassin.tests && spamassassin.tests.length > 0}
<div class="mb-2">
<strong>Tests Triggered:</strong>
<div class="mt-2">
{#each spamassassin.tests as test}
<span class="badge bg-light text-dark me-1 mb-1">{test}</span>
{/each}
</div>
</div>
{/if}
{#if spamassassin.report}
<details class="mt-3">
<summary class="cursor-pointer fw-bold">Full Report</summary>
<pre class="mt-2 small bg-light p-3 rounded">{spamassassin.report}</pre>
</details>
{/if}
</div>
</div>
<style>
.cursor-pointer {
cursor: pointer;
}
details summary {
user-select: none;
}
details summary:hover {
color: var(--bs-primary);
}
</style>

View file

@ -0,0 +1,8 @@
// Component exports
export { default as FeatureCard } from "./FeatureCard.svelte";
export { default as HowItWorksStep } from "./HowItWorksStep.svelte";
export { default as ScoreCard } from "./ScoreCard.svelte";
export { default as CheckCard } from "./CheckCard.svelte";
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
export { default as PendingState } from "./PendingState.svelte";

30
web/src/lib/hey-api.ts Normal file
View file

@ -0,0 +1,30 @@
import type { CreateClientConfig } from "./api/client.gen";
export class NotAuthorizedError extends Error {
constructor(message: string) {
super(message);
this.name = "NotAuthorizedError";
}
}
async function customFetch(url: string, init: RequestInit): Promise<Response> {
const response = await fetch(url, init);
if (response.status === 400) {
const json = await response.json();
if (
json.error ==
"error in openapi3filter.SecurityRequirementsError: security requirements failed: invalid session"
) {
throw new NotAuthorizedError(json.error.substring(80));
}
}
return response;
}
export const createClientConfig: CreateClientConfig = (config) => ({
...config,
baseUrl: "/api/",
fetch: customFetch,
});

1
web/src/lib/index.ts Normal file
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1,150 @@
<script lang="ts">
import { page } from "$app/stores";
let status = $derived($page.status);
let message = $derived($page.error?.message || "An unexpected error occurred");
function getErrorTitle(status: number): string {
switch (status) {
case 404:
return "Page Not Found";
case 403:
return "Access Denied";
case 500:
return "Server Error";
case 503:
return "Service Unavailable";
default:
return "Something Went Wrong";
}
}
function getErrorDescription(status: number): string {
switch (status) {
case 404:
return "The page you're looking for doesn't exist or has been moved.";
case 403:
return "You don't have permission to access this resource.";
case 500:
return "Our server encountered an error while processing your request.";
case 503:
return "The service is temporarily unavailable. Please try again later.";
default:
return "An unexpected error occurred. Please try again.";
}
}
function getErrorIcon(status: number): string {
switch (status) {
case 404:
return "bi-search";
case 403:
return "bi-shield-lock";
case 500:
return "bi-exclamation-triangle";
case 503:
return "bi-clock-history";
default:
return "bi-exclamation-circle";
}
}
</script>
<svelte:head>
<title>{status} - {getErrorTitle(status)} | happyDeliver</title>
</svelte:head>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 text-center fade-in">
<!-- Error Icon -->
<div class="error-icon-wrapper mb-4">
<i class="bi {getErrorIcon(status)} text-danger"></i>
</div>
<!-- Error Status -->
<h1 class="display-1 fw-bold text-primary mb-3">{status}</h1>
<!-- Error Title -->
<h2 class="fw-bold mb-3">{getErrorTitle(status)}</h2>
<!-- Error Description -->
<p class="text-muted mb-4">{getErrorDescription(status)}</p>
<!-- Error Message (if available) -->
{#if message !== getErrorDescription(status)}
<div class="alert alert-light border mb-4" role="alert">
<i class="bi bi-info-circle me-2"></i>
{message}
</div>
{/if}
<!-- Action Buttons -->
<div class="d-flex flex-column flex-sm-row gap-3 justify-content-center">
<a href="/" class="btn btn-primary btn-lg px-4">
<i class="bi bi-house-door me-2"></i>
Go Home
</a>
<button
class="btn btn-outline-primary btn-lg px-4"
onclick={() => window.history.back()}
>
<i class="bi bi-arrow-left me-2"></i>
Go Back
</button>
</div>
<!-- Additional Help -->
{#if status === 404}
<div class="mt-5">
<p class="text-muted small mb-2">Looking for something specific?</p>
<div class="d-flex flex-wrap gap-2 justify-content-center">
<a href="/" class="badge bg-light text-dark text-decoration-none">Home</a>
<a href="/#features" class="badge bg-light text-dark text-decoration-none"
>Features</a
>
<a
href="https://github.com/happyDomain/happydeliver"
class="badge bg-light text-dark text-decoration-none"
>
Documentation
</a>
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
.error-icon-wrapper {
font-size: 6rem;
line-height: 1;
}
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.badge {
padding: 0.5rem 1rem;
font-weight: normal;
transition: all 0.2s ease;
}
.badge:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
</style>

View file

@ -0,0 +1,51 @@
<script lang="ts">
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap-icons/font/bootstrap-icons.css";
import "../app.css";
interface Props {
children?: import("svelte").Snippet;
}
let { children }: Props = $props();
</script>
<div class="min-vh-100 d-flex flex-column">
<nav class="navbar navbar-expand-lg navbar-dark bg-primary shadow-sm">
<div class="container">
<a class="navbar-brand fw-bold" href="/">
<i class="bi bi-envelope-check me-2"></i>
happyDeliver
</a>
<span class="navbar-text text-white-50 small">
Open-Source Email Deliverability Tester
</span>
</div>
</nav>
<main class="flex-grow-1">
{@render children?.()}
</main>
<footer class="bg-dark text-light py-4">
<div class="container text-center">
<p class="mb-1">
<small class="d-flex justify-content-center gap-2">
Open-Source Email Deliverability Testing Platform
<span class="mx-1">&bull;</span>
<a
href="https://github.com/happyDomain/happyDeliver"
class="text-decoration-none"
>
<i class="bi bi-github"></i> GitHub
</a>
<a
href="https://framagit.com/happyDomain/happyDeliver"
class="text-decoration-none"
>
<i class="bi bi-gitlab"></i> GitLab
</a>
</small>
</p>
</div>
</footer>
</div>

216
web/src/routes/+page.svelte Normal file
View file

@ -0,0 +1,216 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { createTest as apiCreateTest } from "$lib/api";
import { FeatureCard, HowItWorksStep } from "$lib/components";
let loading = $state(false);
let error = $state<string | null>(null);
async function createTest() {
loading = true;
error = null;
try {
const response = await apiCreateTest();
if (response.data) {
goto(`/test/${response.data.id}`);
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to create test";
loading = false;
}
}
const features = [
{
icon: "bi-shield-check",
title: "Authentication",
description:
"SPF, DKIM, and DMARC validation with detailed results and recommendations.",
variant: "primary" as const,
},
{
icon: "bi-globe",
title: "DNS Records",
description: "Verify MX, SPF, DKIM, and DMARC records are properly configured.",
variant: "success" as const,
},
{
icon: "bi-bug",
title: "Spam Score",
description: "SpamAssassin analysis with detailed test results and scoring.",
variant: "warning" as const,
},
{
icon: "bi-list-check",
title: "Blacklists",
description: "Check if your IP is listed in major DNS-based blacklists (RBLs).",
variant: "danger" as const,
},
{
icon: "bi-file-text",
title: "Content Analysis",
description: "HTML structure, link validation, image analysis, and more.",
variant: "info" as const,
},
{
icon: "bi-card-heading",
title: "Header Quality",
description: "Validate required headers, check for missing fields and alignment.",
variant: "secondary" as const,
},
{
icon: "bi-bar-chart",
title: "Detailed Scoring",
description:
"0-10 deliverability score with breakdown by category and recommendations.",
variant: "primary" as const,
},
{
icon: "bi-lock",
title: "Privacy First",
description: "Self-hosted solution, your data never leaves your infrastructure.",
variant: "success" as const,
},
];
const steps = [
{
step: 1,
title: "Create Test",
description: "Click the button to generate a unique test email address.",
},
{
step: 2,
title: "Send Email",
description: "Send a test email from your mail server to the provided address.",
},
{
step: 3,
title: "View Results",
description: "Get instant detailed analysis with actionable recommendations.",
},
];
</script>
<svelte:head>
<title>happyDeliver - Email Deliverability Testing</title>
</svelte:head>
<!-- Hero Section -->
<section class="hero py-5">
<div class="container py-5">
<div class="row align-items-center">
<div class="col-lg-8 mx-auto text-center fade-in">
<h1 class="display-3 fw-bold mb-4">Test Your Email Deliverability</h1>
<p class="lead mb-4 opacity-90">
Get detailed insights into your email configuration, authentication, spam score,
and more. Open-source, self-hosted, and privacy-focused.
</p>
<button
class="btn btn-light btn-lg px-5 py-3 shadow"
onclick={createTest}
disabled={loading}
>
{#if loading}
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Creating Test...
{:else}
<i class="bi bi-envelope-plus me-2"></i>
Start Free Test
{/if}
</button>
{#if error}
<div class="alert alert-danger mt-4 d-inline-block" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
{error}
</div>
{/if}
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section class="py-5">
<div class="container py-4">
<div class="row text-center mb-5">
<div class="col-lg-8 mx-auto">
<h2 class="display-5 fw-bold mb-3">Comprehensive Email Analysis</h2>
<p class="text-muted">
Your favorite deliverability tester, open-source and
self-hostable for complete privacy and control.
</p>
</div>
</div>
<div class="row g-4">
{#each features as feature}
<div class="col-md-6 col-lg-3">
<FeatureCard {...feature} />
</div>
{/each}
</div>
</div>
</section>
<!-- How It Works -->
<section class="bg-light py-5">
<div class="container py-4">
<div class="row text-center mb-5">
<div class="col-lg-8 mx-auto">
<h2 class="display-5 fw-bold mb-3">How It Works</h2>
<p class="text-muted">
Simple three-step process to test your email deliverability
</p>
</div>
</div>
<div class="row g-4">
{#each steps as stepData}
<div class="col-md-4">
<HowItWorksStep {...stepData} />
</div>
{/each}
</div>
<div class="text-center mt-5">
<button
class="btn btn-primary btn-lg px-5 py-3"
onclick={createTest}
disabled={loading}
>
{#if loading}
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Creating Test...
{:else}
<i class="bi bi-rocket-takeoff me-2"></i>
Get Started Now
{/if}
</button>
</div>
</div>
</section>
<style>
.hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -0,0 +1,143 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { page } from "$app/state";
import { getTest, getReport } from "$lib/api";
import type { Test, Report } from "$lib/api/types.gen";
import { ScoreCard, CheckCard, SpamAssassinCard, PendingState } from "$lib/components";
let testId = $derived(page.params.test);
let test = $state<Test | null>(null);
let report = $state<Report | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let pollInterval: ReturnType<typeof setInterval> | null = null;
async function fetchTest() {
try {
const testResponse = await getTest({ path: { id: testId } });
if (testResponse.data) {
test = testResponse.data;
if (test.status === "analyzed") {
const reportResponse = await getReport({ path: { id: testId } });
if (reportResponse.data) {
report = reportResponse.data;
}
stopPolling();
}
}
loading = false;
} catch (err) {
error = err instanceof Error ? err.message : "Failed to fetch test";
loading = false;
stopPolling();
}
}
function startPolling() {
pollInterval = setInterval(fetchTest, 3000);
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
onMount(() => {
fetchTest();
startPolling();
});
onDestroy(() => {
stopPolling();
});
</script>
<svelte:head>
<title>{test ? `Test ${test.id.slice(0, 8)} - happyDeliver` : "Loading..."}</title>
</svelte:head>
<div class="container py-5">
{#if loading}
<div class="text-center py-5">
<div
class="spinner-border text-primary"
role="status"
style="width: 3rem; height: 3rem;"
>
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading test...</p>
</div>
{:else if error}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
{error}
</div>
</div>
</div>
{:else if test && test.status !== "analyzed"}
<!-- Pending State -->
<PendingState {test} />
{:else if report}
<!-- Results State -->
<div class="fade-in">
<!-- Score Header -->
<div class="row mb-4">
<div class="col-12">
<ScoreCard score={report.score} summary={report.summary} />
</div>
</div>
<!-- Detailed Checks -->
<div class="row mb-4">
<div class="col-12">
<h3 class="fw-bold mb-3">Detailed Checks</h3>
{#each report.checks as check}
<CheckCard {check} />
{/each}
</div>
</div>
<!-- Additional Information -->
{#if report.spamassassin}
<div class="row mb-4">
<div class="col-12">
<SpamAssassinCard spamassassin={report.spamassassin} />
</div>
</div>
{/if}
<!-- Test Again Button -->
<div class="row">
<div class="col-12 text-center">
<a href="/" class="btn btn-primary btn-lg">
<i class="bi bi-arrow-repeat me-2"></i>
Test Another Email
</a>
</div>
</div>
</div>
{/if}
</div>
<style>
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

19
web/svelte.config.js Normal file
View file

@ -0,0 +1,19 @@
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: "index.html",
}),
paths: {
relative: process.env.MODE === "production",
},
},
};
export default config;

19
web/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

25
web/vite.config.ts Normal file
View file

@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
import { sveltekit } from "@sveltejs/kit/vite";
export default defineConfig({
server: {
hmr: {
port: 10000,
},
},
plugins: [sveltekit()],
test: {
expect: { requireAssertions: true },
projects: [
{
extends: "./vite.config.ts",
test: {
name: "server",
environment: "node",
include: ["src/**/*.{test,spec}.{js,ts}"],
exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"],
},
},
],
},
});