Compare commits
24 commits
master
...
renovate/s
| Author | SHA1 | Date | |
|---|---|---|---|
| 84cec363e2 | |||
| 4ed08c5fa9 | |||
| b47676f234 | |||
| f1b9ac1e27 | |||
| fedb80f7d4 | |||
| 01569d1b21 | |||
| 78c070cdcf | |||
| 74866d210c | |||
| 35ff54b2e1 | |||
| fd5054f886 | |||
| 50b72bb492 | |||
| 19d9c6c859 | |||
| 37babf1ad6 | |||
| aa0d571d77 | |||
| 6dce13cf51 | |||
| d178c1c440 | |||
| f327733009 | |||
| 9d80e5e401 | |||
| 7049e2d012 | |||
| ef433bfbe5 | |||
| 54ad0e1151 | |||
| b96af8349d | |||
| 4f0d0a7e83 | |||
| 7161eaaafd |
82 changed files with 10449 additions and 338 deletions
27
.dockerignore
Normal file
27
.dockerignore
Normal 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
22
.drone-manifest.yml
Normal 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
156
.drone.yml
Normal 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
3
.gitignore
vendored
|
|
@ -17,6 +17,9 @@ vendor/
|
||||||
.env.local
|
.env.local
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Logs files
|
||||||
|
logs/
|
||||||
|
|
||||||
# Database files
|
# Database files
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
|
||||||
100
Dockerfile
Normal file
100
Dockerfile
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Multi-stage Dockerfile for happyDeliver with integrated MTA
|
||||||
|
# Stage 1: Build the Svelte application
|
||||||
|
FROM node:22-alpine AS nodebuild
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY api/ api/
|
||||||
|
COPY web/ web/
|
||||||
|
|
||||||
|
RUN yarn --cwd web install && \
|
||||||
|
yarn --cwd web run generate:api && \
|
||||||
|
yarn --cwd web --offline build
|
||||||
|
|
||||||
|
# Stage 2: Build the Go application
|
||||||
|
FROM golang:1-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache ca-certificates git gcc musl-dev
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
COPY --from=nodebuild /build/web/build/ ./web/build/
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN go generate ./... && \
|
||||||
|
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver
|
||||||
|
|
||||||
|
# Stage 3: Runtime image with Postfix and all filters
|
||||||
|
FROM alpine:3
|
||||||
|
|
||||||
|
# Install all required packages
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
bash \
|
||||||
|
ca-certificates \
|
||||||
|
opendkim \
|
||||||
|
opendkim-utils \
|
||||||
|
opendmarc \
|
||||||
|
postfix \
|
||||||
|
postfix-pcre \
|
||||||
|
postfix-policyd-spf-perl \
|
||||||
|
spamassassin \
|
||||||
|
spamassassin-client \
|
||||||
|
supervisor \
|
||||||
|
sqlite \
|
||||||
|
tzdata \
|
||||||
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
|
# Get test-only version of postfix-policyd-spf-perl
|
||||||
|
ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl
|
||||||
|
RUN chmod +x /usr/bin/postfix-policyd-spf-perl && chmod 755 /usr/bin/postfix-policyd-spf-perl
|
||||||
|
|
||||||
|
# Create happydeliver user and group
|
||||||
|
RUN addgroup -g 1000 happydeliver && \
|
||||||
|
adduser -D -u 1000 -G happydeliver happydeliver
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p /etc/happydeliver \
|
||||||
|
/var/lib/happydeliver \
|
||||||
|
/var/log/happydeliver \
|
||||||
|
/var/spool/postfix/opendkim \
|
||||||
|
/var/spool/postfix/opendmarc \
|
||||||
|
/etc/opendkim/keys \
|
||||||
|
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
|
||||||
|
&& chown -R opendkim:postfix /var/spool/postfix/opendkim \
|
||||||
|
&& chown -R opendmarc:postfix /var/spool/postfix/opendmarc
|
||||||
|
|
||||||
|
# Copy the built application
|
||||||
|
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
|
||||||
|
RUN chmod +x /usr/local/bin/happyDeliver
|
||||||
|
|
||||||
|
# Copy configuration files
|
||||||
|
COPY docker/postfix/ /etc/postfix/
|
||||||
|
COPY docker/opendkim/ /etc/opendkim/
|
||||||
|
COPY docker/opendmarc/ /etc/opendmarc/
|
||||||
|
COPY docker/spamassassin/ /etc/mail/spamassassin/
|
||||||
|
COPY docker/supervisor/ /etc/supervisor/
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
# 25 - SMTP
|
||||||
|
# 8080 - API server
|
||||||
|
EXPOSE 25 8080
|
||||||
|
|
||||||
|
# Default configuration
|
||||||
|
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net
|
||||||
|
|
||||||
|
# Volume for persistent data
|
||||||
|
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
||||||
|
|
||||||
|
# Set entrypoint
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||||
217
README.md
Normal file
217
README.md
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
# happyDeliver - Email Deliverability Tester
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
An open-source email deliverability testing platform that analyzes test emails and provides detailed deliverability reports with scoring.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, SpamAssassin scores, DNS records, blacklist status, content quality, and more
|
||||||
|
- **REST API**: Full-featured API for creating tests and retrieving reports
|
||||||
|
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
|
||||||
|
- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers
|
||||||
|
- **Database Storage**: SQLite or PostgreSQL support
|
||||||
|
- **Configurable**: via environment or config file for all settings
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### With Docker (Recommended)
|
||||||
|
|
||||||
|
The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application.
|
||||||
|
|
||||||
|
#### What's included in the Docker container:
|
||||||
|
|
||||||
|
- **Postfix MTA**: Receives emails on port 25
|
||||||
|
- **OpenDKIM**: DKIM signature verification
|
||||||
|
- **OpenDMARC**: DMARC policy validation
|
||||||
|
- **SpamAssassin**: Spam scoring and analysis
|
||||||
|
- **happyDeliver API**: REST API server on port 8080
|
||||||
|
- **SQLite Database**: Persistent storage for tests and reports
|
||||||
|
|
||||||
|
#### 1. Using docker-compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://git.nemunai.re/happyDomain/happyDeliver.git
|
||||||
|
cd happydeliver
|
||||||
|
|
||||||
|
# Edit docker-compose.yml to set your domain
|
||||||
|
# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables
|
||||||
|
|
||||||
|
# Build and start
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `http://localhost:8080` and SMTP at `localhost:25`.
|
||||||
|
|
||||||
|
#### 2. Using docker build directly
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the image
|
||||||
|
docker build -t happydeliver:latest .
|
||||||
|
|
||||||
|
# Run the container
|
||||||
|
docker run -d \
|
||||||
|
--name happydeliver \
|
||||||
|
-p 25:25 \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
|
||||||
|
-e HOSTNAME=mail.yourdomain.com \
|
||||||
|
-v $(pwd)/data:/var/lib/happydeliver \
|
||||||
|
-v $(pwd)/logs:/var/log/happydeliver \
|
||||||
|
happydeliver:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Build
|
||||||
|
|
||||||
|
#### 1. Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go generate
|
||||||
|
go build -o happyDeliver ./cmd/happyDeliver
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run the API Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./happyDeliver server
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start on `http://localhost:8080` by default.
|
||||||
|
|
||||||
|
#### 3. Integrate with your existing e-mail setup
|
||||||
|
|
||||||
|
It is expected your setup annotate the email with eg. opendkim, spamassassin, ...
|
||||||
|
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
|
||||||
|
|
||||||
|
Choose one of the following way to integrate happyDeliver in your existing setup:
|
||||||
|
|
||||||
|
#### Postfix LMTP Transport
|
||||||
|
|
||||||
|
You'll obtain the best results with a custom [transport rule](https://www.postfix.org/transport.5.html) using LMTP.
|
||||||
|
|
||||||
|
1. Start the happyDeliver server with LMTP enabled (default listens on `127.0.0.1:2525`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./happyDeliver server
|
||||||
|
```
|
||||||
|
|
||||||
|
You can customize the LMTP address with the `-lmtp-addr` flag or in the config file.
|
||||||
|
|
||||||
|
2. Create the file `/etc/postfix/transport_happydeliver` with the following content:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Transport map - route test emails to happyDeliver LMTP server
|
||||||
|
# Pattern: test-<uuid>@yourdomain.com -> LMTP on localhost:2525
|
||||||
|
|
||||||
|
/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Append the created file to `transport_maps` in your `main.cf`:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-transport_maps = texthash:/etc/postfix/transport
|
||||||
|
+transport_maps = texthash:/etc/postfix/transport, pcre:/etc/postfix/transport_happydeliver
|
||||||
|
```
|
||||||
|
|
||||||
|
If your `transport_maps` option is not set, just append this line:
|
||||||
|
|
||||||
|
```
|
||||||
|
transport_maps = pcre:/etc/postfix/transport_happydeliver
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: to use the `pcre:` type, you need to have `postfix-pcre` installed.
|
||||||
|
|
||||||
|
4. Reload Postfix configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
postfix reload
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Create a Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/test
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"email": "test-550e8400@localhost",
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Send your test email to the address above"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Send Test Email
|
||||||
|
|
||||||
|
Send a test email to the address provided (you'll need to configure your MTA to route emails to the analyzer - see MTA Integration below).
|
||||||
|
|
||||||
|
#### 6. Get Report
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/report/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/test` | POST | Create a new deliverability test |
|
||||||
|
| `/api/test/{id}` | GET | Get test metadata and status |
|
||||||
|
| `/api/report/{id}` | GET | Get detailed analysis report |
|
||||||
|
| `/api/report/{id}/raw` | GET | Get raw annotated email |
|
||||||
|
| `/api/status` | GET | Service health and status |
|
||||||
|
|
||||||
|
## Email Analyzer (CLI Mode)
|
||||||
|
|
||||||
|
For manual testing or debugging, you can analyze emails from the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat email.eml | ./happyDeliver analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
Or specify recipient explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** In production, emails are delivered via LMTP (see integration instructions above).
|
||||||
|
|
||||||
|
## Scoring System
|
||||||
|
|
||||||
|
The deliverability score is calculated from 0 to 10 based on:
|
||||||
|
|
||||||
|
- **Authentication (3 pts)**: SPF, DKIM, DMARC validation
|
||||||
|
- **Spam (2 pts)**: SpamAssassin score
|
||||||
|
- **Blacklist (2 pts)**: RBL/DNSBL checks
|
||||||
|
- **Content (2 pts)**: HTML quality, links, images, unsubscribe
|
||||||
|
- **Headers (1 pt)**: Required headers, MIME structure
|
||||||
|
|
||||||
|
**Note:** BIMI (Brand Indicators for Message Identification) is also checked and reported but does not contribute to the score, as it's a branding feature rather than a deliverability factor.
|
||||||
|
|
||||||
|
**Ratings:**
|
||||||
|
- 9-10: Excellent
|
||||||
|
- 7-8.9: Good
|
||||||
|
- 5-6.9: Fair
|
||||||
|
- 3-4.9: Poor
|
||||||
|
- 0-2.9: Critical
|
||||||
|
|
||||||
|
## Funding
|
||||||
|
|
||||||
|
This project is funded through [NGI Zero Core](https://nlnet.nl/core), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/happyDomain).
|
||||||
|
|
||||||
|
[<img src="https://nlnet.nl/logo/banner.png" alt="NLnet foundation logo" width="20%" />](https://nlnet.nl)
|
||||||
|
[<img src="https://nlnet.nl/image/logos/NGI0_tag.svg" alt="NGI Zero Logo" width="20%" />](https://nlnet.nl/core)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
|
||||||
|
|
@ -31,11 +31,11 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- tests
|
- tests
|
||||||
summary: Create a new deliverability test
|
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
|
operationId: createTest
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Test created successfully
|
description: Test email address generated successfully
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
|
@ -51,8 +51,8 @@ paths:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- tests
|
- tests
|
||||||
summary: Get test metadata
|
summary: Get test status
|
||||||
description: Retrieve test status and metadata
|
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
|
operationId: getTest
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
|
|
@ -63,13 +63,13 @@ paths:
|
||||||
format: uuid
|
format: uuid
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Test metadata retrieved successfully
|
description: Test status retrieved successfully
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Test'
|
$ref: '#/components/schemas/Test'
|
||||||
'404':
|
'500':
|
||||||
description: Test not found
|
description: Internal server error
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
|
@ -168,8 +168,8 @@ components:
|
||||||
example: "test-550e8400@example.com"
|
example: "test-550e8400@example.com"
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending, received, analyzed, failed]
|
enum: [pending, analyzed]
|
||||||
description: Current test status
|
description: Current test status (pending = no report yet, analyzed = report available)
|
||||||
example: "analyzed"
|
example: "analyzed"
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -353,6 +353,10 @@ components:
|
||||||
$ref: '#/components/schemas/AuthResult'
|
$ref: '#/components/schemas/AuthResult'
|
||||||
dmarc:
|
dmarc:
|
||||||
$ref: '#/components/schemas/AuthResult'
|
$ref: '#/components/schemas/AuthResult'
|
||||||
|
bimi:
|
||||||
|
$ref: '#/components/schemas/AuthResult'
|
||||||
|
arc:
|
||||||
|
$ref: '#/components/schemas/ARCResult'
|
||||||
|
|
||||||
AuthResult:
|
AuthResult:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -376,6 +380,29 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: Additional details about the result
|
description: Additional details about the result
|
||||||
|
|
||||||
|
ARCResult:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- result
|
||||||
|
properties:
|
||||||
|
result:
|
||||||
|
type: string
|
||||||
|
enum: [pass, fail, none]
|
||||||
|
description: Overall ARC chain validation result
|
||||||
|
example: "pass"
|
||||||
|
chain_valid:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the ARC chain signatures are valid
|
||||||
|
example: true
|
||||||
|
chain_length:
|
||||||
|
type: integer
|
||||||
|
description: Number of ARC sets in the chain
|
||||||
|
example: 2
|
||||||
|
details:
|
||||||
|
type: string
|
||||||
|
description: Additional details about ARC validation
|
||||||
|
example: "ARC chain valid with 2 intermediaries"
|
||||||
|
|
||||||
SpamAssassinResult:
|
SpamAssassinResult:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|
@ -420,7 +447,7 @@ components:
|
||||||
example: "example.com"
|
example: "example.com"
|
||||||
record_type:
|
record_type:
|
||||||
type: string
|
type: string
|
||||||
enum: [MX, SPF, DKIM, DMARC]
|
enum: [MX, SPF, DKIM, DMARC, BIMI]
|
||||||
description: DNS record type
|
description: DNS record type
|
||||||
example: "SPF"
|
example: "SPF"
|
||||||
status:
|
status:
|
||||||
|
|
|
||||||
BIN
banner.webp
Normal file
BIN
banner.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
|
|
@ -22,14 +22,20 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/app"
|
||||||
|
"git.happydns.org/happyDeliver/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const version = "0.1.0-dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
fmt.Println("Mail Tester - Email Deliverability Testing Platform")
|
fmt.Println("happyDeliver - Email Deliverability Testing Platform")
|
||||||
fmt.Println("Version: 0.1.0-dev")
|
fmt.Printf("Version: %s\n", version)
|
||||||
|
|
||||||
cfg, err := config.ConsolidateConfig()
|
cfg, err := config.ConsolidateConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -40,13 +46,15 @@ func main() {
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case "server":
|
case "server":
|
||||||
log.Println("Starting API server...")
|
if err := app.RunServer(cfg); err != nil {
|
||||||
// TODO: Start API server
|
log.Fatalf("Server error: %v", err)
|
||||||
|
}
|
||||||
case "analyze":
|
case "analyze":
|
||||||
log.Println("Starting email analyzer...")
|
if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil {
|
||||||
// TODO: Start email analyzer (LMTP/pipe mode)
|
log.Fatalf("Analyzer error: %v", err)
|
||||||
|
}
|
||||||
case "version":
|
case "version":
|
||||||
fmt.Println("0.1.0-dev")
|
fmt.Println(version)
|
||||||
default:
|
default:
|
||||||
fmt.Printf("Unknown command: %s\n", command)
|
fmt.Printf("Unknown command: %s\n", command)
|
||||||
printUsage()
|
printUsage()
|
||||||
|
|
@ -56,9 +64,9 @@ func main() {
|
||||||
|
|
||||||
func printUsage() {
|
func printUsage() {
|
||||||
fmt.Println("\nCommand availables:")
|
fmt.Println("\nCommand availables:")
|
||||||
fmt.Println(" happyDeliver server - Start the API server")
|
fmt.Println(" happyDeliver server - Start the API server")
|
||||||
fmt.Println(" happyDeliver analyze [-recipient EMAIL] - Analyze email from stdin (MDA mode)")
|
fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
|
||||||
fmt.Println(" happyDeliver version - Print version information")
|
fmt.Println(" happyDeliver version - Print version information")
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
flag.Usage()
|
flag.Usage()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
services:
|
||||||
|
happydeliver:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: happydeliver:latest
|
||||||
|
container_name: happydeliver
|
||||||
|
hostname: mail.happydeliver.local
|
||||||
|
|
||||||
|
environment:
|
||||||
|
# Set your domain and hostname
|
||||||
|
DOMAIN: happydeliver.local
|
||||||
|
HOSTNAME: mail.happydeliver.local
|
||||||
|
|
||||||
|
ports:
|
||||||
|
# SMTP port
|
||||||
|
- "25:25"
|
||||||
|
# API port
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Persistent database storage
|
||||||
|
- ./data:/var/lib/happydeliver
|
||||||
|
# Log files
|
||||||
|
- ./logs:/var/log/happydeliver
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
|
logs:
|
||||||
164
docker/README.md
Normal file
164
docker/README.md
Normal 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
66
docker/entrypoint.sh
Normal 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 "$@"
|
||||||
39
docker/opendkim/opendkim.conf
Normal file
39
docker/opendkim/opendkim.conf
Normal 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
|
||||||
41
docker/opendmarc/opendmarc.conf
Normal file
41
docker/opendmarc/opendmarc.conf
Normal 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
10
docker/postfix/aliases
Normal 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
41
docker/postfix/main.cf
Normal 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
83
docker/postfix/master.cf
Normal 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}
|
||||||
4
docker/postfix/transport_maps
Normal file
4
docker/postfix/transport_maps
Normal 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
|
||||||
50
docker/spamassassin/local.cf
Normal file
50
docker/spamassassin/local.cf
Normal 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
|
||||||
76
docker/supervisor/supervisord.conf
Normal file
76
docker/supervisor/supervisord.conf
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/var/log/happydeliver/supervisord.log
|
||||||
|
pidfile=/run/supervisord.pid
|
||||||
|
loglevel=info
|
||||||
|
|
||||||
|
[unix_http_server]
|
||||||
|
file=/run/supervisord.sock
|
||||||
|
chmod=0700
|
||||||
|
|
||||||
|
[rpcinterface:supervisor]
|
||||||
|
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||||
|
|
||||||
|
[supervisorctl]
|
||||||
|
serverurl=unix:///run/supervisord.sock
|
||||||
|
|
||||||
|
# syslogd service
|
||||||
|
[program:syslogd]
|
||||||
|
command=/sbin/syslogd -n
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=9
|
||||||
|
|
||||||
|
# OpenDKIM service
|
||||||
|
[program:opendkim]
|
||||||
|
command=/usr/sbin/opendkim -f -x /etc/opendkim/opendkim.conf
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=10
|
||||||
|
stdout_logfile=/var/log/happydeliver/opendkim.log
|
||||||
|
stderr_logfile=/var/log/happydeliver/opendkim_error.log
|
||||||
|
user=opendkim
|
||||||
|
group=mail
|
||||||
|
|
||||||
|
# OpenDMARC service
|
||||||
|
[program:opendmarc]
|
||||||
|
command=/usr/sbin/opendmarc -f -c /etc/opendmarc/opendmarc.conf
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=11
|
||||||
|
stdout_logfile=/var/log/happydeliver/opendmarc.log
|
||||||
|
stderr_logfile=/var/log/happydeliver/opendmarc_error.log
|
||||||
|
user=opendmarc
|
||||||
|
group=mail
|
||||||
|
|
||||||
|
# SpamAssassin daemon
|
||||||
|
[program:spamd]
|
||||||
|
command=/usr/sbin/spamd --max-children 5 --helper-home-dir /var/lib/spamassassin --syslog stderr --pidfile /var/run/spamd.pid
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=12
|
||||||
|
stdout_logfile=/var/log/happydeliver/spamd.log
|
||||||
|
stderr_logfile=/var/log/happydeliver/spamd_error.log
|
||||||
|
user=root
|
||||||
|
|
||||||
|
# Postfix service
|
||||||
|
[program:postfix]
|
||||||
|
command=/usr/sbin/postfix start-fg
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=20
|
||||||
|
stdout_logfile=/var/log/happydeliver/postfix.log
|
||||||
|
stderr_logfile=/var/log/happydeliver/postfix_error.log
|
||||||
|
user=root
|
||||||
|
|
||||||
|
# happyDeliver API server
|
||||||
|
[program:happydeliver-api]
|
||||||
|
command=/usr/local/bin/happyDeliver server -config /etc/happydeliver/config.yaml
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=30
|
||||||
|
stdout_logfile=/var/log/happydeliver/api.log
|
||||||
|
stderr_logfile=/var/log/happydeliver/api_error.log
|
||||||
|
user=happydeliver
|
||||||
|
environment=GIN_MODE="release"
|
||||||
28
go.mod
28
go.mod
|
|
@ -3,11 +3,15 @@ module git.happydns.org/happyDeliver
|
||||||
go 1.24.6
|
go 1.24.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/emersion/go-smtp v0.24.0
|
||||||
github.com/getkin/kin-openapi v0.132.0
|
github.com/getkin/kin-openapi v0.132.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/oapi-codegen/runtime v1.1.2
|
github.com/oapi-codegen/runtime v1.1.2
|
||||||
golang.org/x/net v0.42.0
|
golang.org/x/net v0.46.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/driver/sqlite v1.6.0
|
||||||
|
gorm.io/gorm v1.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -16,6 +20,7 @@ require (
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // 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/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
|
|
@ -25,12 +30,19 @@ require (
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // 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/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
|
|
@ -40,7 +52,7 @@ require (
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // 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/jsonpath v0.6.0 // indirect
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
|
@ -48,12 +60,12 @@ require (
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
golang.org/x/crypto v0.43.0 // indirect
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
golang.org/x/mod v0.28.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/text v0.27.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
golang.org/x/tools v0.34.0 // indirect
|
golang.org/x/tools v0.37.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|
|
||||||
57
go.sum
57
go.sum
|
|
@ -17,6 +17,10 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||||
|
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.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 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
|
@ -68,6 +72,18 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/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/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/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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
|
@ -88,6 +104,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
|
@ -126,8 +144,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/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 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
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.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
|
||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
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 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||||
|
|
@ -143,6 +161,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
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.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.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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
|
@ -162,11 +181,11 @@ golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
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.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.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
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-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
|
@ -174,13 +193,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|
@ -196,21 +215,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
@ -242,3 +261,9 @@ gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||||
|
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
|
|
||||||
194
internal/api/handlers.go
Normal file
194
internal/api/handlers.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,10 @@
|
||||||
|
|
||||||
package api
|
package api
|
||||||
|
|
||||||
|
func stringPtr(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
// PtrTo returns a pointer to the provided value
|
// PtrTo returns a pointer to the provided value
|
||||||
func PtrTo[T any](v T) *T {
|
func PtrTo[T any](v T) *T {
|
||||||
return &v
|
return &v
|
||||||
|
|
|
||||||
108
internal/app/cleanup.go
Normal file
108
internal/app/cleanup.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
143
internal/app/cli_analyzer.go
Normal file
143
internal/app/cli_analyzer.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
"git.happydns.org/happyDeliver/internal/config"
|
||||||
|
"git.happydns.org/happyDeliver/pkg/analyzer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunAnalyzer runs the standalone email analyzer (from stdin)
|
||||||
|
func RunAnalyzer(cfg *config.Config, args []string, reader io.Reader, writer io.Writer) error {
|
||||||
|
// Parse command-line flags
|
||||||
|
fs := flag.NewFlagSet("analyze", flag.ExitOnError)
|
||||||
|
jsonOutput := fs.Bool("json", false, "Output results as JSON")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Email analyzer ready, reading from stdin...")
|
||||||
|
|
||||||
|
// Read email from stdin
|
||||||
|
emailData, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read email from stdin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create analyzer with configuration
|
||||||
|
emailAnalyzer := analyzer.NewEmailAnalyzer(cfg)
|
||||||
|
|
||||||
|
// Analyze the email (using a dummy test ID for standalone mode)
|
||||||
|
result, err := emailAnalyzer.AnalyzeEmailBytes(emailData, uuid.New())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to analyze email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Analyzing email from: %s", result.Email.From)
|
||||||
|
|
||||||
|
// Output results
|
||||||
|
if *jsonOutput {
|
||||||
|
return outputJSON(result, writer)
|
||||||
|
}
|
||||||
|
return outputHumanReadable(result, emailAnalyzer, writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// outputJSON outputs the report as JSON
|
||||||
|
func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error {
|
||||||
|
reportJSON, err := json.MarshalIndent(result.Report, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal report: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(writer, string(reportJSON))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// outputHumanReadable outputs a human-readable summary
|
||||||
|
func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error {
|
||||||
|
// Header
|
||||||
|
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
|
||||||
|
fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT")
|
||||||
|
fmt.Fprintln(writer, strings.Repeat("=", 70))
|
||||||
|
|
||||||
|
// Score summary
|
||||||
|
summary := emailAnalyzer.GetScoreSummaryText(result)
|
||||||
|
fmt.Fprintln(writer, summary)
|
||||||
|
|
||||||
|
// Detailed checks
|
||||||
|
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||||
|
fmt.Fprintln(writer, "DETAILED CHECK RESULTS")
|
||||||
|
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||||
|
|
||||||
|
// Group checks by category
|
||||||
|
categories := make(map[api.CheckCategory][]api.Check)
|
||||||
|
for _, check := range result.Report.Checks {
|
||||||
|
categories[check.Category] = append(categories[check.Category], check)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print checks by category
|
||||||
|
categoryOrder := []api.CheckCategory{
|
||||||
|
api.Authentication,
|
||||||
|
api.Dns,
|
||||||
|
api.Blacklist,
|
||||||
|
api.Content,
|
||||||
|
api.Headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, category := range categoryOrder {
|
||||||
|
checks, ok := categories[category]
|
||||||
|
if !ok || len(checks) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(writer, "\n%s:\n", category)
|
||||||
|
for _, check := range checks {
|
||||||
|
statusSymbol := "✓"
|
||||||
|
if check.Status == api.CheckStatusFail {
|
||||||
|
statusSymbol = "✗"
|
||||||
|
} else if check.Status == api.CheckStatusWarn {
|
||||||
|
statusSymbol = "⚠"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message)
|
||||||
|
if check.Advice != nil && *check.Advice != "" {
|
||||||
|
fmt.Fprintf(writer, " → %s\n", *check.Advice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
89
internal/app/server.go
Normal file
89
internal/app/server.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -33,9 +33,11 @@ func declareFlags(o *Config) {
|
||||||
flag.StringVar(&o.Database.DSN, "database-dsn", o.Database.DSN, "Database DSN or path")
|
flag.StringVar(&o.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.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
|
||||||
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
|
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
|
||||||
|
flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address")
|
||||||
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
|
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
|
||||||
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
|
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
|
||||||
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
|
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
|
||||||
|
flag.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
|
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,12 @@ import (
|
||||||
|
|
||||||
// Config represents the application configuration
|
// Config represents the application configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DevProxy string
|
DevProxy string
|
||||||
Bind string
|
Bind string
|
||||||
Database DatabaseConfig
|
Database DatabaseConfig
|
||||||
Email EmailConfig
|
Email EmailConfig
|
||||||
Analysis AnalysisConfig
|
Analysis AnalysisConfig
|
||||||
|
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseConfig contains database connection settings
|
// DatabaseConfig contains database connection settings
|
||||||
|
|
@ -52,6 +53,7 @@ type DatabaseConfig struct {
|
||||||
type EmailConfig struct {
|
type EmailConfig struct {
|
||||||
Domain string
|
Domain string
|
||||||
TestAddressPrefix string
|
TestAddressPrefix string
|
||||||
|
LMTPAddr string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalysisConfig contains timeout and behavior settings for email analysis
|
// AnalysisConfig contains timeout and behavior settings for email analysis
|
||||||
|
|
@ -64,8 +66,9 @@ type AnalysisConfig struct {
|
||||||
// DefaultConfig returns a configuration with sensible defaults
|
// DefaultConfig returns a configuration with sensible defaults
|
||||||
func DefaultConfig() *Config {
|
func DefaultConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
DevProxy: "",
|
DevProxy: "",
|
||||||
Bind: ":8080",
|
Bind: ":8080",
|
||||||
|
ReportRetention: 0, // Keep reports forever by default
|
||||||
Database: DatabaseConfig{
|
Database: DatabaseConfig{
|
||||||
Type: "sqlite",
|
Type: "sqlite",
|
||||||
DSN: "happydeliver.db",
|
DSN: "happydeliver.db",
|
||||||
|
|
@ -73,6 +76,7 @@ func DefaultConfig() *Config {
|
||||||
Email: EmailConfig{
|
Email: EmailConfig{
|
||||||
Domain: "happydeliver.local",
|
Domain: "happydeliver.local",
|
||||||
TestAddressPrefix: "test-",
|
TestAddressPrefix: "test-",
|
||||||
|
LMTPAddr: "127.0.0.1:2525",
|
||||||
},
|
},
|
||||||
Analysis: AnalysisConfig{
|
Analysis: AnalysisConfig{
|
||||||
DNSTimeout: 5 * time.Second,
|
DNSTimeout: 5 * time.Second,
|
||||||
|
|
|
||||||
144
internal/lmtp/server.go
Normal file
144
internal/lmtp/server.go
Normal 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
|
||||||
|
}
|
||||||
176
internal/receiver/receiver.go
Normal file
176
internal/receiver/receiver.go
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package receiver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/config"
|
||||||
|
"git.happydns.org/happyDeliver/internal/storage"
|
||||||
|
"git.happydns.org/happyDeliver/pkg/analyzer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmailReceiver handles incoming emails from the MTA
|
||||||
|
type EmailReceiver struct {
|
||||||
|
storage storage.Storage
|
||||||
|
config *config.Config
|
||||||
|
analyzer *analyzer.EmailAnalyzer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEmailReceiver creates a new email receiver
|
||||||
|
func NewEmailReceiver(store storage.Storage, cfg *config.Config) *EmailReceiver {
|
||||||
|
return &EmailReceiver{
|
||||||
|
storage: store,
|
||||||
|
config: cfg,
|
||||||
|
analyzer: analyzer.NewEmailAnalyzer(cfg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessEmail reads an email from the reader, analyzes it, and stores the results
|
||||||
|
func (r *EmailReceiver) ProcessEmail(emailData io.Reader, recipientEmail string) error {
|
||||||
|
// Read the entire email
|
||||||
|
rawEmail, err := io.ReadAll(emailData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.ProcessEmailBytes(rawEmail, recipientEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessEmailBytes processes an email from a byte slice
|
||||||
|
func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string) error {
|
||||||
|
|
||||||
|
log.Printf("Received email for %s (%d bytes)", recipientEmail, len(rawEmail))
|
||||||
|
|
||||||
|
// Extract test ID from recipient email address
|
||||||
|
testID, err := r.extractTestID(recipientEmail)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to extract test ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Extracted test ID: %s", testID)
|
||||||
|
|
||||||
|
// Check if a report already exists for this test ID
|
||||||
|
reportExists, err := r.storage.ReportExists(testID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check report existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reportExists {
|
||||||
|
log.Printf("Report already exists for test %s, skipping analysis", testID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Analyzing email for test %s", testID)
|
||||||
|
|
||||||
|
// Analyze the email using the shared analyzer
|
||||||
|
result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to analyze email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score)
|
||||||
|
|
||||||
|
// Marshal report to JSON
|
||||||
|
reportJSON, err := json.Marshal(result.Report)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the report
|
||||||
|
if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil {
|
||||||
|
return fmt.Errorf("failed to store report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Report stored successfully for test %s", testID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTestID extracts the UUID from the test email address
|
||||||
|
// Expected format: test-<uuid>@domain.com
|
||||||
|
func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
|
||||||
|
// Remove angle brackets if present (e.g., <test-uuid@domain.com>)
|
||||||
|
email = strings.Trim(email, "<>")
|
||||||
|
|
||||||
|
// Extract the local part (before @)
|
||||||
|
parts := strings.Split(email, "@")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return uuid.Nil, fmt.Errorf("invalid email format: %s", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
localPart := parts[0]
|
||||||
|
|
||||||
|
// Remove the prefix (e.g., "test-")
|
||||||
|
if !strings.HasPrefix(localPart, r.config.Email.TestAddressPrefix) {
|
||||||
|
return uuid.Nil, fmt.Errorf("email does not have expected prefix: %s", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
|
||||||
|
|
||||||
|
// Parse UUID
|
||||||
|
testID, err := uuid.Parse(uuidStr)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return testID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractRecipientFromHeaders attempts to extract the recipient email from email headers
|
||||||
|
// This is useful when the email is piped and we need to determine the recipient
|
||||||
|
func ExtractRecipientFromHeaders(rawEmail []byte) (string, error) {
|
||||||
|
emailStr := string(rawEmail)
|
||||||
|
|
||||||
|
// Look for common recipient headers
|
||||||
|
headerPatterns := []string{
|
||||||
|
`(?i)^To:\s*(.+)$`,
|
||||||
|
`(?i)^X-Original-To:\s*(.+)$`,
|
||||||
|
`(?i)^Delivered-To:\s*(.+)$`,
|
||||||
|
`(?i)^Envelope-To:\s*(.+)$`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern := range headerPatterns {
|
||||||
|
re := regexp.MustCompile(pattern)
|
||||||
|
matches := re.FindStringSubmatch(emailStr)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
recipient := strings.TrimSpace(matches[1])
|
||||||
|
// Clean up the email address
|
||||||
|
recipient = strings.Trim(recipient, "<>")
|
||||||
|
// Take only the first email if there are multiple
|
||||||
|
if idx := strings.Index(recipient, ","); idx != -1 {
|
||||||
|
recipient = recipient[:idx]
|
||||||
|
}
|
||||||
|
if recipient != "" {
|
||||||
|
return recipient, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("could not extract recipient from email headers")
|
||||||
|
}
|
||||||
46
internal/storage/models.go
Normal file
46
internal/storage/models.go
Normal 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
136
internal/storage/storage.go
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
ErrAlreadyExists = errors.New("already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Storage interface defines operations for persisting and retrieving data
|
||||||
|
type Storage interface {
|
||||||
|
// Report operations
|
||||||
|
CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error)
|
||||||
|
GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error)
|
||||||
|
ReportExists(testID uuid.UUID) (bool, error)
|
||||||
|
DeleteOldReports(olderThan time.Time) (int64, error)
|
||||||
|
|
||||||
|
// Close closes the database connection
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBStorage implements Storage using GORM
|
||||||
|
type DBStorage struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStorage creates a new storage instance based on database type
|
||||||
|
func NewStorage(dbType, dsn string) (Storage, error) {
|
||||||
|
var dialector gorm.Dialector
|
||||||
|
|
||||||
|
switch dbType {
|
||||||
|
case "sqlite":
|
||||||
|
dialector = sqlite.Open(dsn)
|
||||||
|
case "postgres":
|
||||||
|
dialector = postgres.Open(dsn)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(dialector, &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-migrate the schema
|
||||||
|
if err := db.AutoMigrate(&Report{}); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to migrate database schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DBStorage{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReport creates a new report for a test
|
||||||
|
func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) {
|
||||||
|
dbReport := &Report{
|
||||||
|
TestID: testID,
|
||||||
|
RawEmail: rawEmail,
|
||||||
|
ReportJSON: reportJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(dbReport).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbReport, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportExists checks if a report exists for the given test ID
|
||||||
|
func (s *DBStorage) ReportExists(testID uuid.UUID) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
if err := s.db.Model(&Report{}).Where("test_id = ?", testID).Count(&count).Error; err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check report existence: %w", err)
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReport retrieves a report by test ID, returning the raw JSON and email bytes
|
||||||
|
func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
|
||||||
|
var dbReport Report
|
||||||
|
if err := s.db.First(&dbReport, "test_id = ?", testID).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, nil, fmt.Errorf("failed to get report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbReport.ReportJSON, dbReport.RawEmail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOldReports deletes reports older than the specified time
|
||||||
|
func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
|
||||||
|
result := s.db.Where("created_at < ?", olderThan).Delete(&Report{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return 0, fmt.Errorf("failed to delete old reports: %w", result.Error)
|
||||||
|
}
|
||||||
|
return result.RowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection
|
||||||
|
func (s *DBStorage) Close() error {
|
||||||
|
sqlDB, err := s.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
87
pkg/analyzer/analyzer.go
Normal file
87
pkg/analyzer/analyzer.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,14 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse ARC headers if not already parsed from Authentication-Results
|
||||||
|
if results.Arc == nil {
|
||||||
|
results.Arc = a.parseARCHeaders(email)
|
||||||
|
} else {
|
||||||
|
// Enhance the ARC result with chain information from raw headers
|
||||||
|
a.enhanceARCResult(email, results.Arc)
|
||||||
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,6 +112,20 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
||||||
results.Dmarc = a.parseDMARCResult(part)
|
results.Dmarc = a.parseDMARCResult(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse BIMI
|
||||||
|
if strings.HasPrefix(part, "bimi=") {
|
||||||
|
if results.Bimi == nil {
|
||||||
|
results.Bimi = a.parseBIMIResult(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ARC
|
||||||
|
if strings.HasPrefix(part, "arc=") {
|
||||||
|
if results.Arc == nil {
|
||||||
|
results.Arc = a.parseARCResult(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,6 +236,201 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseBIMIResult parses BIMI result from Authentication-Results
|
||||||
|
// Example: bimi=pass header.d=example.com header.selector=default
|
||||||
|
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
|
||||||
|
result := &api.AuthResult{}
|
||||||
|
|
||||||
|
// Extract result (pass, fail, etc.)
|
||||||
|
re := regexp.MustCompile(`bimi=(\w+)`)
|
||||||
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
|
resultStr := strings.ToLower(matches[1])
|
||||||
|
result.Result = api.AuthResultResult(resultStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract domain (header.d or d)
|
||||||
|
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
|
||||||
|
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
|
domain := matches[1]
|
||||||
|
result.Domain = &domain
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract selector (header.selector or selector)
|
||||||
|
selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`)
|
||||||
|
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
|
selector := matches[1]
|
||||||
|
result.Selector = &selector
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract details
|
||||||
|
if idx := strings.Index(part, "("); idx != -1 {
|
||||||
|
endIdx := strings.Index(part[idx:], ")")
|
||||||
|
if endIdx != -1 {
|
||||||
|
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
|
||||||
|
result.Details = &details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseARCResult parses ARC result from Authentication-Results
|
||||||
|
// Example: arc=pass
|
||||||
|
func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
|
||||||
|
result := &api.ARCResult{}
|
||||||
|
|
||||||
|
// Extract result (pass, fail, none)
|
||||||
|
re := regexp.MustCompile(`arc=(\w+)`)
|
||||||
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
|
resultStr := strings.ToLower(matches[1])
|
||||||
|
result.Result = api.ARCResultResult(resultStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract details
|
||||||
|
if idx := strings.Index(part, "("); idx != -1 {
|
||||||
|
endIdx := strings.Index(part[idx:], ")")
|
||||||
|
if endIdx != -1 {
|
||||||
|
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
|
||||||
|
result.Details = &details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseARCHeaders parses ARC headers from email message
|
||||||
|
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
|
||||||
|
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
|
||||||
|
// Get all ARC-related headers
|
||||||
|
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
||||||
|
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
||||||
|
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
|
||||||
|
|
||||||
|
// If no ARC headers present, return nil
|
||||||
|
if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &api.ARCResult{
|
||||||
|
Result: api.ARCResultResultNone,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count the ARC chain length (number of sets)
|
||||||
|
chainLength := len(arcSeal)
|
||||||
|
result.ChainLength = &chainLength
|
||||||
|
|
||||||
|
// Validate the ARC chain
|
||||||
|
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
|
||||||
|
result.ChainValid = &chainValid
|
||||||
|
|
||||||
|
// Determine overall result
|
||||||
|
if chainLength == 0 {
|
||||||
|
result.Result = api.ARCResultResultNone
|
||||||
|
details := "No ARC chain present"
|
||||||
|
result.Details = &details
|
||||||
|
} else if !chainValid {
|
||||||
|
result.Result = api.ARCResultResultFail
|
||||||
|
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
|
||||||
|
result.Details = &details
|
||||||
|
} else {
|
||||||
|
result.Result = api.ARCResultResultPass
|
||||||
|
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
|
||||||
|
result.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// enhanceARCResult enhances an existing ARC result with chain information
|
||||||
|
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
|
||||||
|
if arcResult == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ARC headers
|
||||||
|
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
|
||||||
|
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
||||||
|
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
||||||
|
|
||||||
|
// Set chain length if not already set
|
||||||
|
if arcResult.ChainLength == nil {
|
||||||
|
chainLength := len(arcSeal)
|
||||||
|
arcResult.ChainLength = &chainLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate chain if not already validated
|
||||||
|
if arcResult.ChainValid == nil {
|
||||||
|
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
|
||||||
|
arcResult.ChainValid = &chainValid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateARCChain validates the ARC chain for completeness
|
||||||
|
// Each instance should have all three headers with matching instance numbers
|
||||||
|
func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool {
|
||||||
|
// All three header types should have the same count
|
||||||
|
if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(arcSeal) == 0 {
|
||||||
|
return true // No ARC chain is technically valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract instance numbers from each header type
|
||||||
|
sealInstances := a.extractARCInstances(arcSeal)
|
||||||
|
sigInstances := a.extractARCInstances(arcMessageSig)
|
||||||
|
authInstances := a.extractARCInstances(arcAuthResults)
|
||||||
|
|
||||||
|
// Check that all instance numbers match and are sequential starting from 1
|
||||||
|
if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify instances are sequential from 1 to N
|
||||||
|
for i := 1; i <= len(sealInstances); i++ {
|
||||||
|
if !contains(sealInstances, i) || !contains(sigInstances, i) || !contains(authInstances, i) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractARCInstances extracts instance numbers from ARC headers
|
||||||
|
func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int {
|
||||||
|
var instances []int
|
||||||
|
re := regexp.MustCompile(`i=(\d+)`)
|
||||||
|
|
||||||
|
for _, header := range headers {
|
||||||
|
if matches := re.FindStringSubmatch(header); len(matches) > 1 {
|
||||||
|
var instance int
|
||||||
|
fmt.Sscanf(matches[1], "%d", &instance)
|
||||||
|
instances = append(instances, instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains checks if a slice contains an integer
|
||||||
|
func contains(slice []int, val int) bool {
|
||||||
|
for _, item := range slice {
|
||||||
|
if item == val {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// pluralize returns "y" or "ies" based on count
|
||||||
|
func pluralize(count int) string {
|
||||||
|
if count == 1 {
|
||||||
|
return "y"
|
||||||
|
}
|
||||||
|
return "ies"
|
||||||
|
}
|
||||||
|
|
||||||
// parseLegacySPF attempts to parse SPF from Received-SPF header
|
// parseLegacySPF attempts to parse SPF from Received-SPF header
|
||||||
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
|
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
|
||||||
receivedSPF := email.Header.Get("Received-SPF")
|
receivedSPF := email.Header.Get("Received-SPF")
|
||||||
|
|
@ -288,224 +505,3 @@ func textprotoCanonical(s string) string {
|
||||||
}
|
}
|
||||||
return strings.Join(words, "-")
|
return strings.Join(words, "-")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAuthenticationScore calculates the authentication score (0-3 points)
|
|
||||||
func (a *AuthenticationAnalyzer) GetAuthenticationScore(results *api.AuthenticationResults) float32 {
|
|
||||||
var score float32 = 0.0
|
|
||||||
|
|
||||||
// SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail
|
|
||||||
if results.Spf != nil {
|
|
||||||
switch results.Spf.Result {
|
|
||||||
case api.AuthResultResultPass:
|
|
||||||
score += 1.0
|
|
||||||
case api.AuthResultResultNeutral, api.AuthResultResultSoftfail:
|
|
||||||
score += 0.5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DKIM: 1 point for at least one pass
|
|
||||||
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
|
||||||
for _, dkim := range *results.Dkim {
|
|
||||||
if dkim.Result == api.AuthResultResultPass {
|
|
||||||
score += 1.0
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DMARC: 1 point for pass
|
|
||||||
if results.Dmarc != nil {
|
|
||||||
switch results.Dmarc.Result {
|
|
||||||
case api.AuthResultResultPass:
|
|
||||||
score += 1.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap at 3 points maximum
|
|
||||||
if score > 3.0 {
|
|
||||||
score = 3.0
|
|
||||||
}
|
|
||||||
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateAuthenticationChecks generates check results for authentication
|
|
||||||
func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check {
|
|
||||||
var checks []api.Check
|
|
||||||
|
|
||||||
// SPF check
|
|
||||||
if results.Spf != nil {
|
|
||||||
check := a.generateSPFCheck(results.Spf)
|
|
||||||
checks = append(checks, check)
|
|
||||||
} else {
|
|
||||||
checks = append(checks, api.Check{
|
|
||||||
Category: api.Authentication,
|
|
||||||
Name: "SPF Record",
|
|
||||||
Status: api.CheckStatusWarn,
|
|
||||||
Score: 0.0,
|
|
||||||
Message: "No SPF authentication result found",
|
|
||||||
Severity: api.PtrTo(api.Medium),
|
|
||||||
Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DKIM check
|
|
||||||
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
|
||||||
for i, dkim := range *results.Dkim {
|
|
||||||
check := a.generateDKIMCheck(&dkim, i)
|
|
||||||
checks = append(checks, check)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
checks = append(checks, api.Check{
|
|
||||||
Category: api.Authentication,
|
|
||||||
Name: "DKIM Signature",
|
|
||||||
Status: api.CheckStatusWarn,
|
|
||||||
Score: 0.0,
|
|
||||||
Message: "No DKIM signature found",
|
|
||||||
Severity: api.PtrTo(api.Medium),
|
|
||||||
Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DMARC check
|
|
||||||
if results.Dmarc != nil {
|
|
||||||
check := a.generateDMARCCheck(results.Dmarc)
|
|
||||||
checks = append(checks, check)
|
|
||||||
} else {
|
|
||||||
checks = append(checks, api.Check{
|
|
||||||
Category: api.Authentication,
|
|
||||||
Name: "DMARC Policy",
|
|
||||||
Status: api.CheckStatusWarn,
|
|
||||||
Score: 0.0,
|
|
||||||
Message: "No DMARC authentication result found",
|
|
||||||
Severity: api.PtrTo(api.Medium),
|
|
||||||
Advice: api.PtrTo("Implement DMARC policy for your domain"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return checks
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check {
|
|
||||||
check := api.Check{
|
|
||||||
Category: api.Authentication,
|
|
||||||
Name: "SPF Record",
|
|
||||||
}
|
|
||||||
|
|
||||||
switch spf.Result {
|
|
||||||
case api.AuthResultResultPass:
|
|
||||||
check.Status = api.CheckStatusPass
|
|
||||||
check.Score = 1.0
|
|
||||||
check.Message = "SPF validation passed"
|
|
||||||
check.Severity = api.PtrTo(api.Info)
|
|
||||||
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
|
||||||
case api.AuthResultResultFail:
|
|
||||||
check.Status = api.CheckStatusFail
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = "SPF validation failed"
|
|
||||||
check.Severity = api.PtrTo(api.Critical)
|
|
||||||
check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
|
|
||||||
case api.AuthResultResultSoftfail:
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.5
|
|
||||||
check.Message = "SPF validation softfail"
|
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
|
||||||
check.Advice = api.PtrTo("Review your SPF record configuration")
|
|
||||||
case api.AuthResultResultNeutral:
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.5
|
|
||||||
check.Message = "SPF validation neutral"
|
|
||||||
check.Severity = api.PtrTo(api.Low)
|
|
||||||
check.Advice = api.PtrTo("Consider tightening your SPF policy")
|
|
||||||
default:
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
|
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
|
||||||
check.Advice = api.PtrTo("Review your SPF record configuration")
|
|
||||||
}
|
|
||||||
|
|
||||||
if spf.Domain != nil {
|
|
||||||
details := fmt.Sprintf("Domain: %s", *spf.Domain)
|
|
||||||
check.Details = &details
|
|
||||||
}
|
|
||||||
|
|
||||||
return check
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check {
|
|
||||||
check := api.Check{
|
|
||||||
Category: api.Authentication,
|
|
||||||
Name: fmt.Sprintf("DKIM Signature #%d", index+1),
|
|
||||||
}
|
|
||||||
|
|
||||||
switch dkim.Result {
|
|
||||||
case api.AuthResultResultPass:
|
|
||||||
check.Status = api.CheckStatusPass
|
|
||||||
check.Score = 1.0
|
|
||||||
check.Message = "DKIM signature is valid"
|
|
||||||
check.Severity = api.PtrTo(api.Info)
|
|
||||||
check.Advice = api.PtrTo("Your DKIM signature is properly configured")
|
|
||||||
case api.AuthResultResultFail:
|
|
||||||
check.Status = api.CheckStatusFail
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = "DKIM signature validation failed"
|
|
||||||
check.Severity = api.PtrTo(api.High)
|
|
||||||
check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
|
|
||||||
default:
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
|
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
|
||||||
check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
|
|
||||||
}
|
|
||||||
|
|
||||||
var detailsParts []string
|
|
||||||
if dkim.Domain != nil {
|
|
||||||
detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
|
|
||||||
}
|
|
||||||
if dkim.Selector != nil {
|
|
||||||
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
|
|
||||||
}
|
|
||||||
if len(detailsParts) > 0 {
|
|
||||||
details := strings.Join(detailsParts, ", ")
|
|
||||||
check.Details = &details
|
|
||||||
}
|
|
||||||
|
|
||||||
return check
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check {
|
|
||||||
check := api.Check{
|
|
||||||
Category: api.Authentication,
|
|
||||||
Name: "DMARC Policy",
|
|
||||||
}
|
|
||||||
|
|
||||||
switch dmarc.Result {
|
|
||||||
case api.AuthResultResultPass:
|
|
||||||
check.Status = api.CheckStatusPass
|
|
||||||
check.Score = 1.0
|
|
||||||
check.Message = "DMARC validation passed"
|
|
||||||
check.Severity = api.PtrTo(api.Info)
|
|
||||||
check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
|
|
||||||
case api.AuthResultResultFail:
|
|
||||||
check.Status = api.CheckStatusFail
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = "DMARC validation failed"
|
|
||||||
check.Severity = api.PtrTo(api.High)
|
|
||||||
check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
|
|
||||||
default:
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
|
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
|
||||||
check.Advice = api.PtrTo("Configure DMARC policy for your domain")
|
|
||||||
}
|
|
||||||
|
|
||||||
if dmarc.Domain != nil {
|
|
||||||
details := fmt.Sprintf("Domain: %s", *dmarc.Domain)
|
|
||||||
check.Details = &details
|
|
||||||
}
|
|
||||||
|
|
||||||
return check
|
|
||||||
}
|
|
||||||
304
pkg/analyzer/authentication_checks.go
Normal file
304
pkg/analyzer/authentication_checks.go
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateAuthenticationChecks generates check results for authentication
|
||||||
|
func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check {
|
||||||
|
var checks []api.Check
|
||||||
|
|
||||||
|
// SPF check
|
||||||
|
if results.Spf != nil {
|
||||||
|
check := a.generateSPFCheck(results.Spf)
|
||||||
|
checks = append(checks, check)
|
||||||
|
} else {
|
||||||
|
checks = append(checks, api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "SPF Record",
|
||||||
|
Status: api.CheckStatusWarn,
|
||||||
|
Score: 0.0,
|
||||||
|
Message: "No SPF authentication result found",
|
||||||
|
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||||
|
Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DKIM check
|
||||||
|
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||||
|
for i, dkim := range *results.Dkim {
|
||||||
|
check := a.generateDKIMCheck(&dkim, i)
|
||||||
|
checks = append(checks, check)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checks = append(checks, api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "DKIM Signature",
|
||||||
|
Status: api.CheckStatusWarn,
|
||||||
|
Score: 0.0,
|
||||||
|
Message: "No DKIM signature found",
|
||||||
|
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||||
|
Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARC check
|
||||||
|
if results.Dmarc != nil {
|
||||||
|
check := a.generateDMARCCheck(results.Dmarc)
|
||||||
|
checks = append(checks, check)
|
||||||
|
} else {
|
||||||
|
checks = append(checks, api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "DMARC Policy",
|
||||||
|
Status: api.CheckStatusWarn,
|
||||||
|
Score: 0.0,
|
||||||
|
Message: "No DMARC authentication result found",
|
||||||
|
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||||
|
Advice: api.PtrTo("Implement DMARC policy for your domain"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIMI check (optional, informational only)
|
||||||
|
if results.Bimi != nil {
|
||||||
|
check := a.generateBIMICheck(results.Bimi)
|
||||||
|
checks = append(checks, check)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ARC check (optional, for forwarded emails)
|
||||||
|
if results.Arc != nil {
|
||||||
|
check := a.generateARCCheck(results.Arc)
|
||||||
|
checks = append(checks, check)
|
||||||
|
}
|
||||||
|
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check {
|
||||||
|
check := api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "SPF Record",
|
||||||
|
}
|
||||||
|
|
||||||
|
switch spf.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
check.Status = api.CheckStatusPass
|
||||||
|
check.Score = 1.0
|
||||||
|
check.Message = "SPF validation passed"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
|
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
||||||
|
case api.AuthResultResultFail:
|
||||||
|
check.Status = api.CheckStatusFail
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "SPF validation failed"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||||
|
check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
|
||||||
|
case api.AuthResultResultSoftfail:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.5
|
||||||
|
check.Message = "SPF validation softfail"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
|
check.Advice = api.PtrTo("Review your SPF record configuration")
|
||||||
|
case api.AuthResultResultNeutral:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.5
|
||||||
|
check.Message = "SPF validation neutral"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
|
check.Advice = api.PtrTo("Consider tightening your SPF policy")
|
||||||
|
default:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
|
check.Advice = api.PtrTo("Review your SPF record configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
if spf.Domain != nil {
|
||||||
|
details := fmt.Sprintf("Domain: %s", *spf.Domain)
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check {
|
||||||
|
check := api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: fmt.Sprintf("DKIM Signature #%d", index+1),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch dkim.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
check.Status = api.CheckStatusPass
|
||||||
|
check.Score = 1.0
|
||||||
|
check.Message = "DKIM signature is valid"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
|
check.Advice = api.PtrTo("Your DKIM signature is properly configured")
|
||||||
|
case api.AuthResultResultFail:
|
||||||
|
check.Status = api.CheckStatusFail
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "DKIM signature validation failed"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
|
check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
|
||||||
|
default:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
|
check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailsParts []string
|
||||||
|
if dkim.Domain != nil {
|
||||||
|
detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
|
||||||
|
}
|
||||||
|
if dkim.Selector != nil {
|
||||||
|
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
|
||||||
|
}
|
||||||
|
if len(detailsParts) > 0 {
|
||||||
|
details := strings.Join(detailsParts, ", ")
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check {
|
||||||
|
check := api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "DMARC Policy",
|
||||||
|
}
|
||||||
|
|
||||||
|
switch dmarc.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
check.Status = api.CheckStatusPass
|
||||||
|
check.Score = 1.0
|
||||||
|
check.Message = "DMARC validation passed"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
|
check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
|
||||||
|
case api.AuthResultResultFail:
|
||||||
|
check.Status = api.CheckStatusFail
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "DMARC validation failed"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
|
check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
|
||||||
|
default:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
|
check.Advice = api.PtrTo("Configure DMARC policy for your domain")
|
||||||
|
}
|
||||||
|
|
||||||
|
if dmarc.Domain != nil {
|
||||||
|
details := fmt.Sprintf("Domain: %s", *dmarc.Domain)
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check {
|
||||||
|
check := api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "BIMI (Brand Indicators)",
|
||||||
|
}
|
||||||
|
|
||||||
|
switch bimi.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
check.Status = api.CheckStatusPass
|
||||||
|
check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
|
||||||
|
check.Message = "BIMI validation passed"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
|
check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI")
|
||||||
|
case api.AuthResultResultFail:
|
||||||
|
check.Status = api.CheckStatusInfo
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "BIMI validation failed"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
|
check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record")
|
||||||
|
default:
|
||||||
|
check.Status = api.CheckStatusInfo
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result)
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
|
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients")
|
||||||
|
}
|
||||||
|
|
||||||
|
if bimi.Domain != nil {
|
||||||
|
details := fmt.Sprintf("Domain: %s", *bimi.Domain)
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check {
|
||||||
|
check := api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "ARC (Authenticated Received Chain)",
|
||||||
|
}
|
||||||
|
|
||||||
|
switch arc.Result {
|
||||||
|
case api.ARCResultResultPass:
|
||||||
|
check.Status = api.CheckStatusPass
|
||||||
|
check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding)
|
||||||
|
check.Message = "ARC chain validation passed"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
|
check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication")
|
||||||
|
case api.ARCResultResultFail:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "ARC chain validation failed"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
|
check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries")
|
||||||
|
default:
|
||||||
|
check.Status = api.CheckStatusInfo
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "No ARC chain present"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
|
check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build details
|
||||||
|
var detailsParts []string
|
||||||
|
if arc.ChainLength != nil {
|
||||||
|
detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength))
|
||||||
|
}
|
||||||
|
if arc.ChainValid != nil {
|
||||||
|
detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid))
|
||||||
|
}
|
||||||
|
if arc.Details != nil {
|
||||||
|
detailsParts = append(detailsParts, *arc.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(detailsParts) > 0 {
|
||||||
|
details := strings.Join(detailsParts, ", ")
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
846
pkg/analyzer/authentication_test.go
Normal file
846
pkg/analyzer/authentication_test.go
Normal file
|
|
@ -0,0 +1,846 @@
|
||||||
|
// This file is part of the happyDeliver (R) project.
|
||||||
|
// Copyright (c) 2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package analyzer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSPFResult(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
part string
|
||||||
|
expectedResult api.AuthResultResult
|
||||||
|
expectedDomain string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "SPF pass with domain",
|
||||||
|
part: "spf=pass smtp.mailfrom=sender@example.com",
|
||||||
|
expectedResult: api.AuthResultResultPass,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SPF fail",
|
||||||
|
part: "spf=fail smtp.mailfrom=sender@example.com",
|
||||||
|
expectedResult: api.AuthResultResultFail,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SPF neutral",
|
||||||
|
part: "spf=neutral smtp.mailfrom=sender@example.com",
|
||||||
|
expectedResult: api.AuthResultResultNeutral,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SPF softfail",
|
||||||
|
part: "spf=softfail smtp.mailfrom=sender@example.com",
|
||||||
|
expectedResult: api.AuthResultResultSoftfail,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.parseSPFResult(tt.part)
|
||||||
|
|
||||||
|
if result.Result != tt.expectedResult {
|
||||||
|
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||||
|
}
|
||||||
|
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
||||||
|
var gotDomain string
|
||||||
|
if result.Domain != nil {
|
||||||
|
gotDomain = *result.Domain
|
||||||
|
}
|
||||||
|
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDKIMResult(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
part string
|
||||||
|
expectedResult api.AuthResultResult
|
||||||
|
expectedDomain string
|
||||||
|
expectedSelector string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DKIM pass with domain and selector",
|
||||||
|
part: "dkim=pass header.d=example.com header.s=default",
|
||||||
|
expectedResult: api.AuthResultResultPass,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
expectedSelector: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DKIM fail",
|
||||||
|
part: "dkim=fail header.d=example.com header.s=selector1",
|
||||||
|
expectedResult: api.AuthResultResultFail,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
expectedSelector: "selector1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DKIM with short form (d= and s=)",
|
||||||
|
part: "dkim=pass d=example.com s=default",
|
||||||
|
expectedResult: api.AuthResultResultPass,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
expectedSelector: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.parseDKIMResult(tt.part)
|
||||||
|
|
||||||
|
if result.Result != tt.expectedResult {
|
||||||
|
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||||
|
}
|
||||||
|
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
||||||
|
var gotDomain string
|
||||||
|
if result.Domain != nil {
|
||||||
|
gotDomain = *result.Domain
|
||||||
|
}
|
||||||
|
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
||||||
|
}
|
||||||
|
if result.Selector == nil || *result.Selector != tt.expectedSelector {
|
||||||
|
var gotSelector string
|
||||||
|
if result.Selector != nil {
|
||||||
|
gotSelector = *result.Selector
|
||||||
|
}
|
||||||
|
t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDMARCResult(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
part string
|
||||||
|
expectedResult api.AuthResultResult
|
||||||
|
expectedDomain string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DMARC pass",
|
||||||
|
part: "dmarc=pass action=none header.from=example.com",
|
||||||
|
expectedResult: api.AuthResultResultPass,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DMARC fail",
|
||||||
|
part: "dmarc=fail action=quarantine header.from=example.com",
|
||||||
|
expectedResult: api.AuthResultResultFail,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.parseDMARCResult(tt.part)
|
||||||
|
|
||||||
|
if result.Result != tt.expectedResult {
|
||||||
|
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||||
|
}
|
||||||
|
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
||||||
|
var gotDomain string
|
||||||
|
if result.Domain != nil {
|
||||||
|
gotDomain = *result.Domain
|
||||||
|
}
|
||||||
|
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBIMIResult(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
part string
|
||||||
|
expectedResult api.AuthResultResult
|
||||||
|
expectedDomain string
|
||||||
|
expectedSelector string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "BIMI pass with domain and selector",
|
||||||
|
part: "bimi=pass header.d=example.com header.selector=default",
|
||||||
|
expectedResult: api.AuthResultResultPass,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
expectedSelector: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BIMI fail",
|
||||||
|
part: "bimi=fail header.d=example.com header.selector=default",
|
||||||
|
expectedResult: api.AuthResultResultFail,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
expectedSelector: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BIMI with short form (d= and selector=)",
|
||||||
|
part: "bimi=pass d=example.com selector=v1",
|
||||||
|
expectedResult: api.AuthResultResultPass,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
expectedSelector: "v1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BIMI none",
|
||||||
|
part: "bimi=none header.d=example.com",
|
||||||
|
expectedResult: api.AuthResultResultNone,
|
||||||
|
expectedDomain: "example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.parseBIMIResult(tt.part)
|
||||||
|
|
||||||
|
if result.Result != tt.expectedResult {
|
||||||
|
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||||
|
}
|
||||||
|
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
||||||
|
var gotDomain string
|
||||||
|
if result.Domain != nil {
|
||||||
|
gotDomain = *result.Domain
|
||||||
|
}
|
||||||
|
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
||||||
|
}
|
||||||
|
if tt.expectedSelector != "" {
|
||||||
|
if result.Selector == nil || *result.Selector != tt.expectedSelector {
|
||||||
|
var gotSelector string
|
||||||
|
if result.Selector != nil {
|
||||||
|
gotSelector = *result.Selector
|
||||||
|
}
|
||||||
|
t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateAuthSPFCheck(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
spf *api.AuthResult
|
||||||
|
expectedStatus api.CheckStatus
|
||||||
|
expectedScore float32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "SPF pass",
|
||||||
|
spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusPass,
|
||||||
|
expectedScore: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SPF fail",
|
||||||
|
spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultFail,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusFail,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SPF softfail",
|
||||||
|
spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultSoftfail,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusWarn,
|
||||||
|
expectedScore: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SPF neutral",
|
||||||
|
spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultNeutral,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusWarn,
|
||||||
|
expectedScore: 0.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
check := analyzer.generateSPFCheck(tt.spf)
|
||||||
|
|
||||||
|
if check.Status != tt.expectedStatus {
|
||||||
|
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||||
|
}
|
||||||
|
if check.Score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
if check.Category != api.Authentication {
|
||||||
|
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
|
||||||
|
}
|
||||||
|
if check.Name != "SPF Record" {
|
||||||
|
t.Errorf("Name = %q, want %q", check.Name, "SPF Record")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateAuthDKIMCheck(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dkim *api.AuthResult
|
||||||
|
index int
|
||||||
|
expectedStatus api.CheckStatus
|
||||||
|
expectedScore float32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DKIM pass",
|
||||||
|
dkim: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
Selector: api.PtrTo("default"),
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
expectedStatus: api.CheckStatusPass,
|
||||||
|
expectedScore: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DKIM fail",
|
||||||
|
dkim: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultFail,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
Selector: api.PtrTo("default"),
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
expectedStatus: api.CheckStatusFail,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DKIM none",
|
||||||
|
dkim: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultNone,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
Selector: api.PtrTo("default"),
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
expectedStatus: api.CheckStatusWarn,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
check := analyzer.generateDKIMCheck(tt.dkim, tt.index)
|
||||||
|
|
||||||
|
if check.Status != tt.expectedStatus {
|
||||||
|
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||||
|
}
|
||||||
|
if check.Score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
if check.Category != api.Authentication {
|
||||||
|
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
|
||||||
|
}
|
||||||
|
if !strings.Contains(check.Name, "DKIM Signature") {
|
||||||
|
t.Errorf("Name should contain 'DKIM Signature', got %q", check.Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateAuthDMARCCheck(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dmarc *api.AuthResult
|
||||||
|
expectedStatus api.CheckStatus
|
||||||
|
expectedScore float32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DMARC pass",
|
||||||
|
dmarc: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusPass,
|
||||||
|
expectedScore: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DMARC fail",
|
||||||
|
dmarc: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultFail,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusFail,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
check := analyzer.generateDMARCCheck(tt.dmarc)
|
||||||
|
|
||||||
|
if check.Status != tt.expectedStatus {
|
||||||
|
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||||
|
}
|
||||||
|
if check.Score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
if check.Category != api.Authentication {
|
||||||
|
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
|
||||||
|
}
|
||||||
|
if check.Name != "DMARC Policy" {
|
||||||
|
t.Errorf("Name = %q, want %q", check.Name, "DMARC Policy")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateAuthBIMICheck(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bimi *api.AuthResult
|
||||||
|
expectedStatus api.CheckStatus
|
||||||
|
expectedScore float32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "BIMI pass",
|
||||||
|
bimi: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusPass,
|
||||||
|
expectedScore: 0.0, // BIMI doesn't contribute to score
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BIMI fail",
|
||||||
|
bimi: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultFail,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusInfo,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BIMI none",
|
||||||
|
bimi: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultNone,
|
||||||
|
Domain: api.PtrTo("example.com"),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusInfo,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
check := analyzer.generateBIMICheck(tt.bimi)
|
||||||
|
|
||||||
|
if check.Status != tt.expectedStatus {
|
||||||
|
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||||
|
}
|
||||||
|
if check.Score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
if check.Category != api.Authentication {
|
||||||
|
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
|
||||||
|
}
|
||||||
|
if check.Name != "BIMI (Brand Indicators)" {
|
||||||
|
t.Errorf("Name = %q, want %q", check.Name, "BIMI (Brand Indicators)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIMI should always have score of 0.0 (branding feature)
|
||||||
|
if check.Score != 0.0 {
|
||||||
|
t.Error("BIMI should not contribute to deliverability score")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAuthenticationScore(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
results *api.AuthenticationResults
|
||||||
|
expectedScore float32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Perfect authentication (SPF + DKIM + DMARC)",
|
||||||
|
results: &api.AuthenticationResults{
|
||||||
|
Spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
Dkim: &[]api.AuthResult{
|
||||||
|
{Result: api.AuthResultResultPass},
|
||||||
|
},
|
||||||
|
Dmarc: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedScore: 3.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SPF and DKIM only",
|
||||||
|
results: &api.AuthenticationResults{
|
||||||
|
Spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
Dkim: &[]api.AuthResult{
|
||||||
|
{Result: api.AuthResultResultPass},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedScore: 2.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SPF fail, DKIM pass",
|
||||||
|
results: &api.AuthenticationResults{
|
||||||
|
Spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultFail,
|
||||||
|
},
|
||||||
|
Dkim: &[]api.AuthResult{
|
||||||
|
{Result: api.AuthResultResultPass},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedScore: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SPF softfail",
|
||||||
|
results: &api.AuthenticationResults{
|
||||||
|
Spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultSoftfail,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedScore: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No authentication",
|
||||||
|
results: &api.AuthenticationResults{},
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BIMI doesn't affect score",
|
||||||
|
results: &api.AuthenticationResults{
|
||||||
|
Spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
Bimi: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedScore: 1.0, // Only SPF counted, not BIMI
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
scorer := NewDeliverabilityScorer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
score := scorer.GetAuthenticationScore(tt.results)
|
||||||
|
|
||||||
|
if score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateAuthenticationChecks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
results *api.AuthenticationResults
|
||||||
|
expectedChecks int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "All authentication methods present",
|
||||||
|
results: &api.AuthenticationResults{
|
||||||
|
Spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
Dkim: &[]api.AuthResult{
|
||||||
|
{Result: api.AuthResultResultPass},
|
||||||
|
},
|
||||||
|
Dmarc: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
Bimi: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedChecks: 4, // SPF, DKIM, DMARC, BIMI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Without BIMI",
|
||||||
|
results: &api.AuthenticationResults{
|
||||||
|
Spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
Dkim: &[]api.AuthResult{
|
||||||
|
{Result: api.AuthResultResultPass},
|
||||||
|
},
|
||||||
|
Dmarc: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedChecks: 3, // SPF, DKIM, DMARC
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No authentication results",
|
||||||
|
results: &api.AuthenticationResults{},
|
||||||
|
expectedChecks: 3, // SPF, DKIM, DMARC warnings for missing
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "With ARC",
|
||||||
|
results: &api.AuthenticationResults{
|
||||||
|
Spf: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
Dkim: &[]api.AuthResult{
|
||||||
|
{Result: api.AuthResultResultPass},
|
||||||
|
},
|
||||||
|
Dmarc: &api.AuthResult{
|
||||||
|
Result: api.AuthResultResultPass,
|
||||||
|
},
|
||||||
|
Arc: &api.ARCResult{
|
||||||
|
Result: api.ARCResultResultPass,
|
||||||
|
ChainLength: api.PtrTo(2),
|
||||||
|
ChainValid: api.PtrTo(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedChecks: 4, // SPF, DKIM, DMARC, ARC
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
checks := analyzer.GenerateAuthenticationChecks(tt.results)
|
||||||
|
|
||||||
|
if len(checks) != tt.expectedChecks {
|
||||||
|
t.Errorf("Got %d checks, want %d", len(checks), tt.expectedChecks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all checks have the Authentication category
|
||||||
|
for _, check := range checks {
|
||||||
|
if check.Category != api.Authentication {
|
||||||
|
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Authentication)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseARCResult(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
part string
|
||||||
|
expectedResult api.ARCResultResult
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ARC pass",
|
||||||
|
part: "arc=pass",
|
||||||
|
expectedResult: api.ARCResultResultPass,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ARC fail",
|
||||||
|
part: "arc=fail",
|
||||||
|
expectedResult: api.ARCResultResultFail,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ARC none",
|
||||||
|
part: "arc=none",
|
||||||
|
expectedResult: api.ARCResultResultNone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.parseARCResult(tt.part)
|
||||||
|
|
||||||
|
if result.Result != tt.expectedResult {
|
||||||
|
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateARCChain(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
arcAuthResults []string
|
||||||
|
arcMessageSig []string
|
||||||
|
arcSeal []string
|
||||||
|
expectedValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Empty chain is valid",
|
||||||
|
arcAuthResults: []string{},
|
||||||
|
arcMessageSig: []string{},
|
||||||
|
arcSeal: []string{},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid chain with single hop",
|
||||||
|
arcAuthResults: []string{
|
||||||
|
"i=1; example.com; spf=pass",
|
||||||
|
},
|
||||||
|
arcMessageSig: []string{
|
||||||
|
"i=1; a=rsa-sha256; d=example.com",
|
||||||
|
},
|
||||||
|
arcSeal: []string{
|
||||||
|
"i=1; a=rsa-sha256; s=arc; d=example.com",
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid chain with two hops",
|
||||||
|
arcAuthResults: []string{
|
||||||
|
"i=1; example.com; spf=pass",
|
||||||
|
"i=2; relay.com; arc=pass",
|
||||||
|
},
|
||||||
|
arcMessageSig: []string{
|
||||||
|
"i=1; a=rsa-sha256; d=example.com",
|
||||||
|
"i=2; a=rsa-sha256; d=relay.com",
|
||||||
|
},
|
||||||
|
arcSeal: []string{
|
||||||
|
"i=1; a=rsa-sha256; s=arc; d=example.com",
|
||||||
|
"i=2; a=rsa-sha256; s=arc; d=relay.com",
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid chain - missing one header type",
|
||||||
|
arcAuthResults: []string{
|
||||||
|
"i=1; example.com; spf=pass",
|
||||||
|
},
|
||||||
|
arcMessageSig: []string{
|
||||||
|
"i=1; a=rsa-sha256; d=example.com",
|
||||||
|
},
|
||||||
|
arcSeal: []string{},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid chain - non-sequential instances",
|
||||||
|
arcAuthResults: []string{
|
||||||
|
"i=1; example.com; spf=pass",
|
||||||
|
"i=3; relay.com; arc=pass",
|
||||||
|
},
|
||||||
|
arcMessageSig: []string{
|
||||||
|
"i=1; a=rsa-sha256; d=example.com",
|
||||||
|
"i=3; a=rsa-sha256; d=relay.com",
|
||||||
|
},
|
||||||
|
arcSeal: []string{
|
||||||
|
"i=1; a=rsa-sha256; s=arc; d=example.com",
|
||||||
|
"i=3; a=rsa-sha256; s=arc; d=relay.com",
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal)
|
||||||
|
|
||||||
|
if valid != tt.expectedValid {
|
||||||
|
t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateARCCheck(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
arc *api.ARCResult
|
||||||
|
expectedStatus api.CheckStatus
|
||||||
|
expectedScore float32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ARC pass",
|
||||||
|
arc: &api.ARCResult{
|
||||||
|
Result: api.ARCResultResultPass,
|
||||||
|
ChainLength: api.PtrTo(2),
|
||||||
|
ChainValid: api.PtrTo(true),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusPass,
|
||||||
|
expectedScore: 0.0, // ARC doesn't contribute to score
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ARC fail",
|
||||||
|
arc: &api.ARCResult{
|
||||||
|
Result: api.ARCResultResultFail,
|
||||||
|
ChainLength: api.PtrTo(1),
|
||||||
|
ChainValid: api.PtrTo(false),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusWarn,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ARC none",
|
||||||
|
arc: &api.ARCResult{
|
||||||
|
Result: api.ARCResultResultNone,
|
||||||
|
ChainLength: api.PtrTo(0),
|
||||||
|
ChainValid: api.PtrTo(true),
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusInfo,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewAuthenticationAnalyzer()
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
check := analyzer.generateARCCheck(tt.arc)
|
||||||
|
|
||||||
|
if check.Status != tt.expectedStatus {
|
||||||
|
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||||
|
}
|
||||||
|
if check.Score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
if check.Category != api.Authentication {
|
||||||
|
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
|
||||||
|
}
|
||||||
|
if !strings.Contains(check.Name, "ARC") {
|
||||||
|
t.Errorf("Name should contain 'ARC', got %q", check.Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -507,7 +507,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api
|
||||||
if !results.HTMLValid {
|
if !results.HTMLValid {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
check.Message = "HTML structure is invalid"
|
check.Message = "HTML structure is invalid"
|
||||||
if len(results.HTMLErrors) > 0 {
|
if len(results.HTMLErrors) > 0 {
|
||||||
details := strings.Join(results.HTMLErrors, "; ")
|
details := strings.Join(results.HTMLErrors, "; ")
|
||||||
|
|
@ -517,7 +517,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.2
|
check.Score = 0.2
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = "HTML structure is valid"
|
check.Message = "HTML structure is valid"
|
||||||
check.Advice = api.PtrTo("Your HTML is well-formed")
|
check.Advice = api.PtrTo("Your HTML is well-formed")
|
||||||
}
|
}
|
||||||
|
|
@ -552,7 +552,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
|
||||||
if brokenLinks > 0 {
|
if brokenLinks > 0 {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.High)
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks)
|
check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks)
|
||||||
check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability")
|
check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability")
|
||||||
details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks)
|
details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks)
|
||||||
|
|
@ -560,7 +560,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
|
||||||
} else if warningLinks > 0 {
|
} else if warningLinks > 0 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.Low)
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks)
|
check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks)
|
||||||
check.Advice = api.PtrTo("Review links that could not be verified")
|
check.Advice = api.PtrTo("Review links that could not be verified")
|
||||||
details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks)
|
details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks)
|
||||||
|
|
@ -568,7 +568,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.4
|
check.Score = 0.4
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links))
|
check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links))
|
||||||
check.Advice = api.PtrTo("Your links are working properly")
|
check.Advice = api.PtrTo("Your links are working properly")
|
||||||
}
|
}
|
||||||
|
|
@ -601,7 +601,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
|
||||||
if noAltCount == len(results.Images) {
|
if noAltCount == len(results.Images) {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
check.Message = "No images have alt attributes"
|
check.Message = "No images have alt attributes"
|
||||||
check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability")
|
check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability")
|
||||||
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
|
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
|
||||||
|
|
@ -609,7 +609,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
|
||||||
} else if noAltCount > 0 {
|
} else if noAltCount > 0 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.2
|
check.Score = 0.2
|
||||||
check.Severity = api.PtrTo(api.Low)
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount)
|
check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount)
|
||||||
check.Advice = api.PtrTo("Add alt text to all images for better accessibility")
|
check.Advice = api.PtrTo("Add alt text to all images for better accessibility")
|
||||||
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
|
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
|
||||||
|
|
@ -617,7 +617,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = "All images have alt attributes"
|
check.Message = "All images have alt attributes"
|
||||||
check.Advice = api.PtrTo("Your images are properly tagged for accessibility")
|
check.Advice = api.PtrTo("Your images are properly tagged for accessibility")
|
||||||
}
|
}
|
||||||
|
|
@ -636,13 +636,13 @@ func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.
|
||||||
if !results.HasUnsubscribe {
|
if !results.HasUnsubscribe {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.Low)
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
check.Message = "No unsubscribe link found"
|
check.Message = "No unsubscribe link found"
|
||||||
check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)")
|
check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks))
|
check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks))
|
||||||
check.Advice = api.PtrTo("Your email includes an unsubscribe option")
|
check.Advice = api.PtrTo("Your email includes an unsubscribe option")
|
||||||
}
|
}
|
||||||
|
|
@ -662,7 +662,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults)
|
||||||
if consistency < 0.3 {
|
if consistency < 0.3 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.Low)
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
check.Message = "Plain text and HTML versions differ significantly"
|
check.Message = "Plain text and HTML versions differ significantly"
|
||||||
check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content")
|
check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content")
|
||||||
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
||||||
|
|
@ -670,7 +670,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults)
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = "Plain text and HTML versions are consistent"
|
check.Message = "Plain text and HTML versions are consistent"
|
||||||
check.Advice = api.PtrTo("Your multipart email is well-structured")
|
check.Advice = api.PtrTo("Your multipart email is well-structured")
|
||||||
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
||||||
|
|
@ -693,7 +693,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
|
||||||
if ratio > 10.0 {
|
if ratio > 10.0 {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
check.Message = "Email is excessively image-heavy"
|
check.Message = "Email is excessively image-heavy"
|
||||||
check.Advice = api.PtrTo("Reduce the number of images relative to text content")
|
check.Advice = api.PtrTo("Reduce the number of images relative to text content")
|
||||||
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||||
|
|
@ -701,7 +701,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
|
||||||
} else if ratio > 5.0 {
|
} else if ratio > 5.0 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.2
|
check.Score = 0.2
|
||||||
check.Severity = api.PtrTo(api.Low)
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
check.Message = "Email has high image-to-text ratio"
|
check.Message = "Email has high image-to-text ratio"
|
||||||
check.Advice = api.PtrTo("Consider adding more text content relative to images")
|
check.Advice = api.PtrTo("Consider adding more text content relative to images")
|
||||||
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||||
|
|
@ -709,7 +709,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = "Image-to-text ratio is reasonable"
|
check.Message = "Image-to-text ratio is reasonable"
|
||||||
check.Advice = api.PtrTo("Your content has a good balance of images and text")
|
check.Advice = api.PtrTo("Your content has a good balance of images and text")
|
||||||
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||||
|
|
@ -730,7 +730,7 @@ func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) ap
|
||||||
|
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count)
|
check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count)
|
||||||
check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails")
|
check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails")
|
||||||
|
|
||||||
|
|
@ -58,6 +58,7 @@ type DNSResults struct {
|
||||||
SPFRecord *SPFRecord
|
SPFRecord *SPFRecord
|
||||||
DKIMRecords []DKIMRecord
|
DKIMRecords []DKIMRecord
|
||||||
DMARCRecord *DMARCRecord
|
DMARCRecord *DMARCRecord
|
||||||
|
BIMIRecord *BIMIRecord
|
||||||
Errors []string
|
Errors []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +94,17 @@ type DMARCRecord struct {
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BIMIRecord represents a BIMI record
|
||||||
|
type BIMIRecord struct {
|
||||||
|
Selector string
|
||||||
|
Domain string
|
||||||
|
Record string
|
||||||
|
LogoURL string // URL to the brand logo (SVG)
|
||||||
|
VMCURL string // URL to Verified Mark Certificate (optional)
|
||||||
|
Valid bool
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
// AnalyzeDNS performs DNS validation for the email's domain
|
// AnalyzeDNS performs DNS validation for the email's domain
|
||||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults {
|
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults {
|
||||||
// Extract domain from From address
|
// Extract domain from From address
|
||||||
|
|
@ -128,6 +140,9 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
|
||||||
// Check DMARC record
|
// Check DMARC record
|
||||||
results.DMARCRecord = d.checkDMARCRecord(domain)
|
results.DMARCRecord = d.checkDMARCRecord(domain)
|
||||||
|
|
||||||
|
// Check BIMI record (using default selector)
|
||||||
|
results.BIMIRecord = d.checkBIMIRecord(domain, "default")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,6 +410,89 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
|
||||||
|
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord {
|
||||||
|
// BIMI records are at: selector._bimi.domain
|
||||||
|
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
|
||||||
|
if err != nil {
|
||||||
|
return &BIMIRecord{
|
||||||
|
Selector: selector,
|
||||||
|
Domain: domain,
|
||||||
|
Valid: false,
|
||||||
|
Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(txtRecords) == 0 {
|
||||||
|
return &BIMIRecord{
|
||||||
|
Selector: selector,
|
||||||
|
Domain: domain,
|
||||||
|
Valid: false,
|
||||||
|
Error: "No BIMI record found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate all TXT record parts (BIMI can be split)
|
||||||
|
bimiRecord := strings.Join(txtRecords, "")
|
||||||
|
|
||||||
|
// Extract logo URL and VMC URL
|
||||||
|
logoURL := d.extractBIMITag(bimiRecord, "l")
|
||||||
|
vmcURL := d.extractBIMITag(bimiRecord, "a")
|
||||||
|
|
||||||
|
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
|
||||||
|
if !d.validateBIMI(bimiRecord) {
|
||||||
|
return &BIMIRecord{
|
||||||
|
Selector: selector,
|
||||||
|
Domain: domain,
|
||||||
|
Record: bimiRecord,
|
||||||
|
LogoURL: logoURL,
|
||||||
|
VMCURL: vmcURL,
|
||||||
|
Valid: false,
|
||||||
|
Error: "BIMI record appears malformed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &BIMIRecord{
|
||||||
|
Selector: selector,
|
||||||
|
Domain: domain,
|
||||||
|
Record: bimiRecord,
|
||||||
|
LogoURL: logoURL,
|
||||||
|
VMCURL: vmcURL,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractBIMITag extracts a tag value from a BIMI record
|
||||||
|
func (d *DNSAnalyzer) extractBIMITag(record, tag string) string {
|
||||||
|
// Look for tag=value pattern
|
||||||
|
re := regexp.MustCompile(tag + `=([^;]+)`)
|
||||||
|
matches := re.FindStringSubmatch(record)
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return strings.TrimSpace(matches[1])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateBIMI performs basic BIMI record validation
|
||||||
|
func (d *DNSAnalyzer) validateBIMI(record string) bool {
|
||||||
|
// Must start with v=BIMI1
|
||||||
|
if !strings.HasPrefix(record, "v=BIMI1") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have a logo URL tag (l=)
|
||||||
|
if !strings.Contains(record, "l=") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateDNSChecks generates check results for DNS validation
|
// GenerateDNSChecks generates check results for DNS validation
|
||||||
func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
|
func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
|
||||||
var checks []api.Check
|
var checks []api.Check
|
||||||
|
|
@ -421,6 +519,11 @@ func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
|
||||||
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
|
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BIMI record check (optional)
|
||||||
|
if results.BIMIRecord != nil {
|
||||||
|
checks = append(checks, d.generateBIMICheck(results.BIMIRecord))
|
||||||
|
}
|
||||||
|
|
||||||
return checks
|
return checks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,7 +537,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
|
||||||
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
|
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.Critical)
|
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||||
|
|
||||||
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
|
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
|
||||||
check.Message = results.MXRecords[0].Error
|
check.Message = results.MXRecords[0].Error
|
||||||
|
|
@ -445,7 +548,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 1.0
|
check.Score = 1.0
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
|
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
|
||||||
|
|
||||||
// Add details about MX records
|
// Add details about MX records
|
||||||
|
|
@ -474,14 +577,14 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Message = spf.Error
|
check.Message = spf.Error
|
||||||
check.Severity = api.PtrTo(api.High)
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
|
check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
|
||||||
} else {
|
} else {
|
||||||
// If record exists but is invalid, it's a warning
|
// If record exists but is invalid, it's a warning
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.5
|
check.Score = 0.5
|
||||||
check.Message = "SPF record found but appears invalid"
|
check.Message = "SPF record found but appears invalid"
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
|
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
|
||||||
check.Details = &spf.Record
|
check.Details = &spf.Record
|
||||||
}
|
}
|
||||||
|
|
@ -489,7 +592,7 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 1.0
|
check.Score = 1.0
|
||||||
check.Message = "Valid SPF record found"
|
check.Message = "Valid SPF record found"
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Details = &spf.Record
|
check.Details = &spf.Record
|
||||||
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
||||||
}
|
}
|
||||||
|
|
@ -508,7 +611,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
|
check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
|
||||||
check.Severity = api.PtrTo(api.High)
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
|
check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
|
||||||
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
|
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
|
||||||
check.Details = &details
|
check.Details = &details
|
||||||
|
|
@ -516,7 +619,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 1.0
|
check.Score = 1.0
|
||||||
check.Message = "Valid DKIM record found"
|
check.Message = "Valid DKIM record found"
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
|
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
|
||||||
check.Details = &details
|
check.Details = &details
|
||||||
check.Advice = api.PtrTo("Your DKIM record is properly published")
|
check.Advice = api.PtrTo("Your DKIM record is properly published")
|
||||||
|
|
@ -536,13 +639,13 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Message = dmarc.Error
|
check.Message = dmarc.Error
|
||||||
check.Severity = api.PtrTo(api.High)
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
|
check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 1.0
|
check.Score = 1.0
|
||||||
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
|
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Details = &dmarc.Record
|
check.Details = &dmarc.Record
|
||||||
|
|
||||||
// Provide advice based on policy
|
// Provide advice based on policy
|
||||||
|
|
@ -564,3 +667,53 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
|
||||||
|
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateBIMICheck creates a check for BIMI records
|
||||||
|
func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check {
|
||||||
|
check := api.Check{
|
||||||
|
Category: api.Dns,
|
||||||
|
Name: "BIMI Record",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bimi.Valid {
|
||||||
|
// BIMI is optional, so missing record is just informational
|
||||||
|
if bimi.Record == "" {
|
||||||
|
check.Status = api.CheckStatusInfo
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "No BIMI record found (optional)"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
|
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)")
|
||||||
|
} else {
|
||||||
|
// If record exists but is invalid
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error)
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
|
check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)")
|
||||||
|
check.Details = &bimi.Record
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
check.Status = api.CheckStatusPass
|
||||||
|
check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
|
||||||
|
check.Message = "Valid BIMI record found"
|
||||||
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
|
|
||||||
|
// Build details with logo and VMC URLs
|
||||||
|
var detailsParts []string
|
||||||
|
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector))
|
||||||
|
if bimi.LogoURL != "" {
|
||||||
|
detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL))
|
||||||
|
}
|
||||||
|
if bimi.VMCURL != "" {
|
||||||
|
detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL))
|
||||||
|
check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate")
|
||||||
|
} else {
|
||||||
|
check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust")
|
||||||
|
}
|
||||||
|
|
||||||
|
details := strings.Join(detailsParts, ", ")
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
@ -631,3 +631,190 @@ func TestAnalyzeDNS_NoDomain(t *testing.T) {
|
||||||
t.Error("Expected error when no domain can be extracted")
|
t.Error("Expected error when no domain can be extracted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractBIMITag(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
record string
|
||||||
|
tag string
|
||||||
|
expectedValue string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Extract logo URL (l tag)",
|
||||||
|
record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||||
|
tag: "l",
|
||||||
|
expectedValue: "https://example.com/logo.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Extract VMC URL (a tag)",
|
||||||
|
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
||||||
|
tag: "a",
|
||||||
|
expectedValue: "https://example.com/vmc.pem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tag not found",
|
||||||
|
record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||||
|
tag: "a",
|
||||||
|
expectedValue: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tag with spaces",
|
||||||
|
record: "v=BIMI1; l= https://example.com/logo.svg ",
|
||||||
|
tag: "l",
|
||||||
|
expectedValue: "https://example.com/logo.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty record",
|
||||||
|
record: "",
|
||||||
|
tag: "l",
|
||||||
|
expectedValue: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.extractBIMITag(tt.record, tt.tag)
|
||||||
|
if result != tt.expectedValue {
|
||||||
|
t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBIMI(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
record string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid BIMI with logo URL",
|
||||||
|
record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid BIMI with logo and VMC",
|
||||||
|
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid BIMI - no version",
|
||||||
|
record: "l=https://example.com/logo.svg",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid BIMI - wrong version",
|
||||||
|
record: "v=BIMI2; l=https://example.com/logo.svg",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid BIMI - no logo URL",
|
||||||
|
record: "v=BIMI1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid BIMI - empty",
|
||||||
|
record: "",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := analyzer.validateBIMI(tt.record)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateBIMICheck(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bimi *BIMIRecord
|
||||||
|
expectedStatus api.CheckStatus
|
||||||
|
expectedScore float32
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid BIMI with logo only",
|
||||||
|
bimi: &BIMIRecord{
|
||||||
|
Selector: "default",
|
||||||
|
Domain: "example.com",
|
||||||
|
Record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||||
|
LogoURL: "https://example.com/logo.svg",
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusPass,
|
||||||
|
expectedScore: 0.0, // BIMI doesn't contribute to score
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid BIMI with VMC",
|
||||||
|
bimi: &BIMIRecord{
|
||||||
|
Selector: "default",
|
||||||
|
Domain: "example.com",
|
||||||
|
Record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
||||||
|
LogoURL: "https://example.com/logo.svg",
|
||||||
|
VMCURL: "https://example.com/vmc.pem",
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusPass,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No BIMI record (optional)",
|
||||||
|
bimi: &BIMIRecord{
|
||||||
|
Selector: "default",
|
||||||
|
Domain: "example.com",
|
||||||
|
Valid: false,
|
||||||
|
Error: "No BIMI record found",
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusInfo,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid BIMI record",
|
||||||
|
bimi: &BIMIRecord{
|
||||||
|
Selector: "default",
|
||||||
|
Domain: "example.com",
|
||||||
|
Record: "v=BIMI1",
|
||||||
|
Valid: false,
|
||||||
|
Error: "BIMI record appears malformed",
|
||||||
|
},
|
||||||
|
expectedStatus: api.CheckStatusWarn,
|
||||||
|
expectedScore: 0.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
check := analyzer.generateBIMICheck(tt.bimi)
|
||||||
|
|
||||||
|
if check.Status != tt.expectedStatus {
|
||||||
|
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||||
|
}
|
||||||
|
if check.Score != tt.expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
|
||||||
|
}
|
||||||
|
if check.Category != api.Dns {
|
||||||
|
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
|
||||||
|
}
|
||||||
|
if check.Name != "BIMI Record" {
|
||||||
|
t.Errorf("Name = %q, want %q", check.Name, "BIMI Record")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check details for valid BIMI with VMC
|
||||||
|
if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil {
|
||||||
|
if !strings.Contains(*check.Details, "VMC URL") {
|
||||||
|
t.Error("Details should contain VMC URL for valid BIMI with VMC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -279,7 +279,7 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check {
|
||||||
Status: api.CheckStatusWarn,
|
Status: api.CheckStatusWarn,
|
||||||
Score: 1.0,
|
Score: 1.0,
|
||||||
Message: "No public IP addresses found to check",
|
Message: "No public IP addresses found to check",
|
||||||
Severity: api.PtrTo(api.Low),
|
Severity: api.PtrTo(api.CheckSeverityLow),
|
||||||
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
|
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
|
||||||
})
|
})
|
||||||
return checks
|
return checks
|
||||||
|
|
@ -316,22 +316,22 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
|
||||||
if listedCount == 0 {
|
if listedCount == 0 {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
|
check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Advice = api.PtrTo("Your sending IP has a good reputation")
|
check.Advice = api.PtrTo("Your sending IP has a good reputation")
|
||||||
} else if listedCount == 1 {
|
} else if listedCount == 1 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
|
check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
|
check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
|
||||||
} else if listedCount <= 3 {
|
} else if listedCount <= 3 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
||||||
check.Severity = api.PtrTo(api.High)
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
|
check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
||||||
check.Severity = api.PtrTo(api.Critical)
|
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||||
check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL")
|
check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,15 +357,15 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check {
|
||||||
|
|
||||||
// Determine severity based on which RBL
|
// Determine severity based on which RBL
|
||||||
if strings.Contains(rblCheck.RBL, "spamhaus") {
|
if strings.Contains(rblCheck.RBL, "spamhaus") {
|
||||||
check.Severity = api.PtrTo(api.Critical)
|
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||||
advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
|
advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
|
||||||
check.Advice = &advice
|
check.Advice = &advice
|
||||||
} else if strings.Contains(rblCheck.RBL, "spamcop") {
|
} else if strings.Contains(rblCheck.RBL, "spamcop") {
|
||||||
check.Severity = api.PtrTo(api.High)
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
|
advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
|
||||||
check.Advice = &advice
|
check.Advice = &advice
|
||||||
} else {
|
} else {
|
||||||
check.Severity = api.PtrTo(api.High)
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
|
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
|
||||||
check.Advice = &advice
|
check.Advice = &advice
|
||||||
}
|
}
|
||||||
|
|
@ -419,7 +419,7 @@ func TestGenerateListingCheck(t *testing.T) {
|
||||||
Response: "127.0.0.2",
|
Response: "127.0.0.2",
|
||||||
},
|
},
|
||||||
expectedStatus: api.CheckStatusFail,
|
expectedStatus: api.CheckStatusFail,
|
||||||
expectedSeverity: api.Critical,
|
expectedSeverity: api.CheckSeverityCritical,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SpamCop listing",
|
name: "SpamCop listing",
|
||||||
|
|
@ -430,7 +430,7 @@ func TestGenerateListingCheck(t *testing.T) {
|
||||||
Response: "127.0.0.2",
|
Response: "127.0.0.2",
|
||||||
},
|
},
|
||||||
expectedStatus: api.CheckStatusFail,
|
expectedStatus: api.CheckStatusFail,
|
||||||
expectedSeverity: api.High,
|
expectedSeverity: api.CheckSeverityHigh,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Other RBL listing",
|
name: "Other RBL listing",
|
||||||
|
|
@ -441,7 +441,7 @@ func TestGenerateListingCheck(t *testing.T) {
|
||||||
Response: "127.0.0.2",
|
Response: "127.0.0.2",
|
||||||
},
|
},
|
||||||
expectedStatus: api.CheckStatusFail,
|
expectedStatus: api.CheckStatusFail,
|
||||||
expectedSeverity: api.High,
|
expectedSeverity: api.CheckSeverityHigh,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,8 +72,7 @@ func (s *DeliverabilityScorer) CalculateScore(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate individual scores
|
// Calculate individual scores
|
||||||
authAnalyzer := NewAuthenticationAnalyzer()
|
result.AuthScore = s.GetAuthenticationScore(authResults)
|
||||||
result.AuthScore = authAnalyzer.GetAuthenticationScore(authResults)
|
|
||||||
|
|
||||||
spamAnalyzer := NewSpamAssassinAnalyzer()
|
spamAnalyzer := NewSpamAssassinAnalyzer()
|
||||||
result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult)
|
result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult)
|
||||||
|
|
@ -351,13 +350,13 @@ func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage)
|
||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.4
|
check.Score = 0.4
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = "All required headers are present"
|
check.Message = "All required headers are present"
|
||||||
check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
|
check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.Critical)
|
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||||
check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
|
check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
|
||||||
check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
|
check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
|
||||||
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
||||||
|
|
@ -386,13 +385,13 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa
|
||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = "All recommended headers are present"
|
check.Message = "All recommended headers are present"
|
||||||
check.Advice = api.PtrTo("Your email includes all recommended headers")
|
check.Advice = api.PtrTo("Your email includes all recommended headers")
|
||||||
} else if len(missing) < len(recommendedHeaders) {
|
} else if len(missing) < len(recommendedHeaders) {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.15
|
check.Score = 0.15
|
||||||
check.Severity = api.PtrTo(api.Low)
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
|
check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
|
||||||
check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
|
check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
|
||||||
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
||||||
|
|
@ -400,7 +399,7 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
check.Message = "Missing all recommended headers"
|
check.Message = "Missing all recommended headers"
|
||||||
check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
|
check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
|
||||||
}
|
}
|
||||||
|
|
@ -420,20 +419,20 @@ func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.C
|
||||||
if messageID == "" {
|
if messageID == "" {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.High)
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
check.Message = "Message-ID header is missing"
|
check.Message = "Message-ID header is missing"
|
||||||
check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
|
check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
|
||||||
} else if !s.isValidMessageID(messageID) {
|
} else if !s.isValidMessageID(messageID) {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.05
|
check.Score = 0.05
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
check.Message = "Message-ID format is invalid"
|
check.Message = "Message-ID format is invalid"
|
||||||
check.Advice = api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>")
|
check.Advice = api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>")
|
||||||
check.Details = &messageID
|
check.Details = &messageID
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.1
|
check.Score = 0.1
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = "Message-ID is properly formatted"
|
check.Message = "Message-ID is properly formatted"
|
||||||
check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
|
check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
|
||||||
check.Details = &messageID
|
check.Details = &messageID
|
||||||
|
|
@ -452,13 +451,13 @@ func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) a
|
||||||
if len(email.Parts) == 0 {
|
if len(email.Parts) == 0 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.Low)
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||||
check.Message = "No MIME parts detected"
|
check.Message = "No MIME parts detected"
|
||||||
check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
|
check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.2
|
check.Score = 0.2
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
|
check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
|
||||||
check.Advice = api.PtrTo("Your email has proper MIME structure")
|
check.Advice = api.PtrTo("Your email has proper MIME structure")
|
||||||
|
|
||||||
|
|
@ -504,3 +503,43 @@ func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string {
|
||||||
|
|
||||||
return summary.String()
|
return summary.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAuthenticationScore calculates the authentication score (0-3 points)
|
||||||
|
func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 {
|
||||||
|
var score float32 = 0.0
|
||||||
|
|
||||||
|
// SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail
|
||||||
|
if results.Spf != nil {
|
||||||
|
switch results.Spf.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
score += 1.0
|
||||||
|
case api.AuthResultResultNeutral, api.AuthResultResultSoftfail:
|
||||||
|
score += 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DKIM: 1 point for at least one pass
|
||||||
|
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||||
|
for _, dkim := range *results.Dkim {
|
||||||
|
if dkim.Result == api.AuthResultResultPass {
|
||||||
|
score += 1.0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARC: 1 point for pass
|
||||||
|
if results.Dmarc != nil {
|
||||||
|
switch results.Dmarc.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
score += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at 3 points maximum
|
||||||
|
if score > 3.0 {
|
||||||
|
score = 3.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
@ -86,7 +86,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss
|
||||||
|
|
||||||
// Parse X-Spam-Report header for detailed test results
|
// Parse X-Spam-Report header for detailed test results
|
||||||
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
||||||
result.RawReport = reportHeader
|
result.RawReport = strings.Replace(reportHeader, " * ", "\n * ", -1)
|
||||||
a.parseSpamReport(reportHeader, result)
|
a.parseSpamReport(reportHeader, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,20 +140,25 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass
|
||||||
// Format varies, but typically:
|
// Format varies, but typically:
|
||||||
// * 1.5 TEST_NAME Description of test
|
// * 1.5 TEST_NAME Description of test
|
||||||
// * 0.0 TEST_NAME2 Description
|
// * 0.0 TEST_NAME2 Description
|
||||||
|
// Note: mail.Header.Get() joins continuation lines, so newlines are removed.
|
||||||
|
// We split on '*' to separate individual tests.
|
||||||
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
|
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
|
||||||
// Split by lines
|
// The report header has been joined by mail.Header.Get(), so we split on '*'
|
||||||
lines := strings.Split(report, "\n")
|
// Each segment starting with '*' is either a test line or continuation
|
||||||
|
segments := strings.Split(report, "*")
|
||||||
|
|
||||||
// Regex to match test lines: * score TEST_NAME Description
|
// Regex to match test lines: score TEST_NAME Description
|
||||||
testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
|
// Format: " 0.0 TEST_NAME Description" or " -0.1 TEST_NAME Description"
|
||||||
|
testRe := regexp.MustCompile(`^\s*(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, segment := range segments {
|
||||||
line = strings.TrimSpace(line)
|
segment = strings.TrimSpace(segment)
|
||||||
if line == "" {
|
if segment == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := testRe.FindStringSubmatch(line)
|
// Try to match as a test line
|
||||||
|
matches := testRe.FindStringSubmatch(segment)
|
||||||
if len(matches) > 3 {
|
if len(matches) > 3 {
|
||||||
testName := matches[2]
|
testName := matches[2]
|
||||||
score, _ := strconv.ParseFloat(matches[1], 64)
|
score, _ := strconv.ParseFloat(matches[1], 64)
|
||||||
|
|
@ -217,7 +222,7 @@ func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinRe
|
||||||
Status: api.CheckStatusWarn,
|
Status: api.CheckStatusWarn,
|
||||||
Score: 0.0,
|
Score: 0.0,
|
||||||
Message: "No SpamAssassin headers found",
|
Message: "No SpamAssassin headers found",
|
||||||
Severity: api.PtrTo(api.Medium),
|
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||||
Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
|
Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
|
||||||
})
|
})
|
||||||
return checks
|
return checks
|
||||||
|
|
@ -260,27 +265,27 @@ func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult)
|
||||||
if score <= 0 {
|
if score <= 0 {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
|
check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
|
check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
|
||||||
} else if score < required {
|
} else if score < required {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
|
check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Advice = api.PtrTo("Your email passes spam filters")
|
check.Advice = api.PtrTo("Your email passes spam filters")
|
||||||
} else if score < required*1.5 {
|
} else if score < required*1.5 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
|
check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
|
check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
|
||||||
} else if score < required*2 {
|
} else if score < required*2 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
|
check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
|
||||||
check.Severity = api.PtrTo(api.High)
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
|
check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
|
check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
|
||||||
check.Severity = api.PtrTo(api.Critical)
|
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||||
check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
|
check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,10 +312,10 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec
|
||||||
// Negative indicator (increases spam score)
|
// Negative indicator (increases spam score)
|
||||||
if detail.Score > 2.0 {
|
if detail.Score > 2.0 {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Severity = api.PtrTo(api.High)
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Severity = api.PtrTo(api.Medium)
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||||
}
|
}
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
|
check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
|
||||||
|
|
@ -320,7 +325,7 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec
|
||||||
// Positive indicator (decreases spam score)
|
// Positive indicator (decreases spam score)
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 1.0
|
check.Score = 1.0
|
||||||
check.Severity = api.PtrTo(api.Info)
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||||
check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score)
|
check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score)
|
||||||
advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score)
|
advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score)
|
||||||
check.Advice = &advice
|
check.Advice = &advice
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
package analyzer
|
package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -480,6 +481,176 @@ func TestGenerateTestCheck(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec
|
||||||
|
X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
|
||||||
|
DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED,
|
||||||
|
RCVD_IN_VALIDITY_RPBL_BLOCKED,RCVD_IN_VALIDITY_SAFE_BLOCKED,
|
||||||
|
SPF_HELO_NONE,SPF_PASS autolearn=disabled version=4.0.1
|
||||||
|
X-Spam-Level:
|
||||||
|
X-Spam-Report:
|
||||||
|
* 0.0 RCVD_IN_VALIDITY_SAFE_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
|
||||||
|
* to Validity was blocked. See
|
||||||
|
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
|
||||||
|
* more information.
|
||||||
|
* [80.67.179.207 listed in sa-accredit.habeas.com]
|
||||||
|
* 0.0 RCVD_IN_VALIDITY_RPBL_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
|
||||||
|
* to Validity was blocked. See
|
||||||
|
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
|
||||||
|
* more information.
|
||||||
|
* [80.67.179.207 listed in bl.score.senderscore.com]
|
||||||
|
* 0.0 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED RBL: ADMINISTRATOR NOTICE: The
|
||||||
|
* query to Validity was blocked. See
|
||||||
|
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
|
||||||
|
* more information.
|
||||||
|
* [80.67.179.207 listed in sa-trusted.bondedsender.org]
|
||||||
|
* -0.0 SPF_PASS SPF: sender matches SPF record
|
||||||
|
* 0.0 SPF_HELO_NONE SPF: HELO does not publish an SPF Record
|
||||||
|
* -0.1 DKIM_VALID Message has at least one valid DKIM or DK signature
|
||||||
|
* 0.1 DKIM_SIGNED Message has a DKIM or DK signature, not necessarily
|
||||||
|
* valid
|
||||||
|
* -0.1 DKIM_VALID_AU Message has a valid DKIM or DK signature from author's
|
||||||
|
* domain
|
||||||
|
Date: Sun, 19 Oct 2025 08:37:30 +0000
|
||||||
|
Message-ID: <aPSjR57mUnCAt7sp@happydomain.org>
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
Content-Disposition: inline
|
||||||
|
Content-Transfer-Encoding: 8bit
|
||||||
|
|
||||||
|
BODY`
|
||||||
|
|
||||||
|
// TestAnalyzeRealEmailExample tests the analyzer with the real example email file
|
||||||
|
func TestAnalyzeRealEmailExample(t *testing.T) {
|
||||||
|
// Parse the email using the standard net/mail package
|
||||||
|
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithSpamassassinHeader))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse email: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create analyzer and analyze SpamAssassin headers
|
||||||
|
analyzer := NewSpamAssassinAnalyzer()
|
||||||
|
result := analyzer.AnalyzeSpamAssassin(email)
|
||||||
|
|
||||||
|
// Validate that we got a result
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Expected SpamAssassin result, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IsSpam flag (should be false for this email)
|
||||||
|
if result.IsSpam {
|
||||||
|
t.Error("IsSpam should be false for real_example.eml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate score (should be -0.1)
|
||||||
|
expectedScore := -0.1
|
||||||
|
if result.Score != expectedScore {
|
||||||
|
t.Errorf("Score = %v, want %v", result.Score, expectedScore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required score (should be 5.0)
|
||||||
|
expectedRequired := 5.0
|
||||||
|
if result.RequiredScore != expectedRequired {
|
||||||
|
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate version
|
||||||
|
if !strings.Contains(result.Version, "SpamAssassin") {
|
||||||
|
t.Errorf("Version should contain 'SpamAssassin', got: %s", result.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that tests were extracted
|
||||||
|
if len(result.Tests) == 0 {
|
||||||
|
t.Error("Expected tests to be extracted, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for expected tests from the real email
|
||||||
|
expectedTests := map[string]bool{
|
||||||
|
"DKIM_SIGNED": true,
|
||||||
|
"DKIM_VALID": true,
|
||||||
|
"DKIM_VALID_AU": true,
|
||||||
|
"SPF_PASS": true,
|
||||||
|
"SPF_HELO_NONE": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testName := range result.Tests {
|
||||||
|
if expectedTests[testName] {
|
||||||
|
t.Logf("Found expected test: %s", testName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that test details were parsed from X-Spam-Report
|
||||||
|
if len(result.TestDetails) == 0 {
|
||||||
|
t.Error("Expected test details to be parsed from X-Spam-Report, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log what we actually got for debugging
|
||||||
|
t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails))
|
||||||
|
for name, detail := range result.TestDetails {
|
||||||
|
t.Logf(" %s: score=%v, description=%s", name, detail.Score, detail.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define expected test details with their scores
|
||||||
|
expectedTestDetails := map[string]float64{
|
||||||
|
"SPF_PASS": -0.0,
|
||||||
|
"SPF_HELO_NONE": 0.0,
|
||||||
|
"DKIM_VALID": -0.1,
|
||||||
|
"DKIM_SIGNED": 0.1,
|
||||||
|
"DKIM_VALID_AU": -0.1,
|
||||||
|
"RCVD_IN_VALIDITY_SAFE_BLOCKED": 0.0,
|
||||||
|
"RCVD_IN_VALIDITY_RPBL_BLOCKED": 0.0,
|
||||||
|
"RCVD_IN_VALIDITY_CERTIFIED_BLOCKED": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate over expected tests and verify they exist in TestDetails
|
||||||
|
for testName, expectedScore := range expectedTestDetails {
|
||||||
|
detail, ok := result.TestDetails[testName]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("Expected test %s not found in TestDetails", testName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if detail.Score != expectedScore {
|
||||||
|
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore)
|
||||||
|
}
|
||||||
|
if detail.Description == "" {
|
||||||
|
t.Errorf("Test %s should have a description", testName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetSpamAssassinScore
|
||||||
|
score := analyzer.GetSpamAssassinScore(result)
|
||||||
|
if score != 2.0 {
|
||||||
|
t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GenerateSpamAssassinChecks
|
||||||
|
checks := analyzer.GenerateSpamAssassinChecks(result)
|
||||||
|
if len(checks) < 1 {
|
||||||
|
t.Fatal("Expected at least 1 check, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main check should be PASS with excellent score
|
||||||
|
mainCheck := checks[0]
|
||||||
|
if mainCheck.Status != api.CheckStatusPass {
|
||||||
|
t.Errorf("Main check status = %v, want %v", mainCheck.Status, api.CheckStatusPass)
|
||||||
|
}
|
||||||
|
if mainCheck.Category != api.Spam {
|
||||||
|
t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
|
||||||
|
}
|
||||||
|
if !strings.Contains(mainCheck.Message, "spam score") {
|
||||||
|
t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message)
|
||||||
|
}
|
||||||
|
if mainCheck.Score != 2.0 {
|
||||||
|
t.Errorf("Main check score = %v, want 2.0", mainCheck.Score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log all checks for debugging
|
||||||
|
t.Logf("Generated %d checks:", len(checks))
|
||||||
|
for i, check := range checks {
|
||||||
|
t.Logf(" Check %d: %s - %s (score: %.1f, status: %s)",
|
||||||
|
i+1, check.Name, check.Message, check.Score, check.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to compare string slices
|
// Helper function to compare string slices
|
||||||
func stringSliceEqual(a, b []string) bool {
|
func stringSliceEqual(a, b []string) bool {
|
||||||
if len(a) != len(b) {
|
if len(a) != len(b) {
|
||||||
7
renovate.json
Normal file
7
renovate.json
Normal 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
26
web/.gitignore
vendored
Normal 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
1
web/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
||||||
9
web/.prettierignore
Normal file
9
web/.prettierignore
Normal 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
13
web/.prettierrc
Normal 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
43
web/assets.go
Normal 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
41
web/eslint.config.js
Normal 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
12
web/openapi-ts.config.ts
Normal 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: "$lib/hey-api.ts",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
4444
web/package-lock.json
generated
Normal file
4444
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
web/package.json
Normal file
43
web/package.json
Normal 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
171
web/routes.go
Normal 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
152
web/src/app.css
Normal 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
13
web/src/app.d.ts
vendored
Normal 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
11
web/src/app.html
Normal 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>
|
||||||
74
web/src/lib/components/CheckCard.svelte
Normal file
74
web/src/lib/components/CheckCard.svelte
Normal 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>
|
||||||
46
web/src/lib/components/EmailAddressDisplay.svelte
Normal file
46
web/src/lib/components/EmailAddressDisplay.svelte
Normal 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>
|
||||||
33
web/src/lib/components/FeatureCard.svelte
Normal file
33
web/src/lib/components/FeatureCard.svelte
Normal 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>
|
||||||
17
web/src/lib/components/HowItWorksStep.svelte
Normal file
17
web/src/lib/components/HowItWorksStep.svelte
Normal 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>
|
||||||
108
web/src/lib/components/PendingState.svelte
Normal file
108
web/src/lib/components/PendingState.svelte
Normal 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>
|
||||||
71
web/src/lib/components/ScoreCard.svelte
Normal file
71
web/src/lib/components/ScoreCard.svelte
Normal 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>
|
||||||
65
web/src/lib/components/SpamAssassinCard.svelte
Normal file
65
web/src/lib/components/SpamAssassinCard.svelte
Normal 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>
|
||||||
8
web/src/lib/components/index.ts
Normal file
8
web/src/lib/components/index.ts
Normal 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
30
web/src/lib/hey-api.ts
Normal 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
1
web/src/lib/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
150
web/src/routes/+error.svelte
Normal file
150
web/src/routes/+error.svelte
Normal 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>
|
||||||
51
web/src/routes/+layout.svelte
Normal file
51
web/src/routes/+layout.svelte
Normal 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">•</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>
|
||||||
222
web/src/routes/+page.svelte
Normal file
222
web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
<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, DMARC, and BIMI validation with detailed results and recommendations.",
|
||||||
|
variant: "primary" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "bi-patch-check",
|
||||||
|
title: "BIMI Support",
|
||||||
|
description: "Brand Indicators for Message Identification - verify your brand logo configuration.",
|
||||||
|
variant: "info" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "bi-globe",
|
||||||
|
title: "DNS Records",
|
||||||
|
description: "Verify MX, SPF, DKIM, DMARC, and BIMI 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>
|
||||||
143
web/src/routes/test/[test]/+page.svelte
Normal file
143
web/src/routes/test/[test]/+page.svelte
Normal 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
19
web/svelte.config.js
Normal 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
19
web/tsconfig.json
Normal 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
25
web/vite.config.ts
Normal 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}"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue