Compare commits
14 commits
master
...
renovate/s
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e35912b8a | |||
| afe3b81304 | |||
| ac57440c2e | |||
| 99988a7fd2 | |||
| 0adddc60a0 | |||
| 10238b5b54 | |||
| bb1dd2a85e | |||
| f8e6a2f314 | |||
| 924d80bdca | |||
| 6a4909c1a7 | |||
| 4cd184779e | |||
| 6abb95c625 | |||
| 395ea2122e | |||
| e1356ebc22 |
67 changed files with 9658 additions and 56 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
|
||||
*.local
|
||||
|
||||
# Logs files
|
||||
logs/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
|
|
|
|||
99
Dockerfile
Normal file
99
Dockerfile
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# Multi-stage Dockerfile for happyDeliver with integrated MTA
|
||||
# Stage 1: Build the Svelte application
|
||||
FROM node:22-alpine AS nodebuild
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY api/ api/
|
||||
COPY web/ web/
|
||||
|
||||
RUN yarn --cwd web install && \
|
||||
yarn --cwd web run generate:api && \
|
||||
yarn --cwd web --offline build
|
||||
|
||||
# Stage 2: Build the Go application
|
||||
FROM golang:1-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache ca-certificates git gcc musl-dev
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
COPY --from=nodebuild /build/web/build/ ./web/build/
|
||||
|
||||
# Build the application
|
||||
RUN go generate ./... && \
|
||||
CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o happyDeliver ./cmd/happyDeliver
|
||||
|
||||
# Stage 3: Runtime image with Postfix and all filters
|
||||
FROM alpine:3
|
||||
|
||||
# Install all required packages
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
ca-certificates \
|
||||
opendkim \
|
||||
opendkim-utils \
|
||||
opendmarc \
|
||||
postfix \
|
||||
postfix-pcre \
|
||||
postfix-policyd-spf-perl \
|
||||
spamassassin \
|
||||
spamassassin-client \
|
||||
supervisor \
|
||||
sqlite \
|
||||
tzdata \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# Get test-only version of postfix-policyd-spf-perl
|
||||
ADD https://git.nemunai.re/happyDomain/postfix-policyd-spf-perl/raw/branch/master/postfix-policyd-spf-perl /usr/bin/postfix-policyd-spf-perl
|
||||
|
||||
# Create happydeliver user and group
|
||||
RUN addgroup -g 1000 happydeliver && \
|
||||
adduser -D -u 1000 -G happydeliver happydeliver
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /etc/happydeliver \
|
||||
/var/lib/happydeliver \
|
||||
/var/log/happydeliver \
|
||||
/var/spool/postfix/opendkim \
|
||||
/var/spool/postfix/opendmarc \
|
||||
/etc/opendkim/keys \
|
||||
&& chown -R happydeliver:happydeliver /var/lib/happydeliver /var/log/happydeliver \
|
||||
&& chown -R opendkim:postfix /var/spool/postfix/opendkim \
|
||||
&& chown -R opendmarc:postfix /var/spool/postfix/opendmarc
|
||||
|
||||
# Copy the built application
|
||||
COPY --from=builder /build/happyDeliver /usr/local/bin/happyDeliver
|
||||
RUN chmod +x /usr/local/bin/happyDeliver
|
||||
|
||||
# Copy configuration files
|
||||
COPY docker/postfix/ /etc/postfix/
|
||||
COPY docker/opendkim/ /etc/opendkim/
|
||||
COPY docker/opendmarc/ /etc/opendmarc/
|
||||
COPY docker/spamassassin/ /etc/mail/spamassassin/
|
||||
COPY docker/supervisor/ /etc/supervisor/
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Expose ports
|
||||
# 25 - SMTP
|
||||
# 8080 - API server
|
||||
EXPOSE 25 8080
|
||||
|
||||
# Default configuration
|
||||
ENV HAPPYDELIVER_DATABASE_TYPE=sqlite HAPPYDELIVER_DATABASE_DSN=/var/lib/happydeliver/happydeliver.db HAPPYDELIVER_DOMAIN=happydeliver.local HAPPYDELIVER_ADDRESS_PREFIX=test- HAPPYDELIVER_DNS_TIMEOUT=5s HAPPYDELIVER_HTTP_TIMEOUT=10s HAPPYDELIVER_RBL=zen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,dnsbl.sorbs.net,dnsbl-1.uceprotect.net,bl.mailspike.net
|
||||
|
||||
# Volume for persistent data
|
||||
VOLUME ["/var/lib/happydeliver", "/var/log/happydeliver"]
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
213
README.md
Normal file
213
README.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# happyDeliver
|
||||
|
||||
An open-source email deliverability testing platform that analyzes test emails and provides detailed deliverability reports with scoring.
|
||||
|
||||
## Features
|
||||
|
||||
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more
|
||||
- **REST API**: Full-featured API for creating tests and retrieving reports
|
||||
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
|
||||
- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers
|
||||
- **Database Storage**: SQLite or PostgreSQL support
|
||||
- **Configurable**: via environment or config file for all settings
|
||||
|
||||
## Quick Start
|
||||
|
||||
### With Docker (Recommended)
|
||||
|
||||
The easiest way to run happyDeliver is using the all-in-one Docker container that includes Postfix, OpenDKIM, OpenDMARC, SpamAssassin, and the happyDeliver application.
|
||||
|
||||
#### What's included in the Docker container:
|
||||
|
||||
- **Postfix MTA**: Receives emails on port 25
|
||||
- **OpenDKIM**: DKIM signature verification
|
||||
- **OpenDMARC**: DMARC policy validation
|
||||
- **SpamAssassin**: Spam scoring and analysis
|
||||
- **happyDeliver API**: REST API server on port 8080
|
||||
- **SQLite Database**: Persistent storage for tests and reports
|
||||
|
||||
#### 1. Using docker-compose
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://git.nemunai.re/happyDomain/happyDeliver.git
|
||||
cd happydeliver
|
||||
|
||||
# Edit docker-compose.yml to set your domain
|
||||
# Change HAPPYDELIVER_DOMAIN and HOSTNAME environment variables
|
||||
|
||||
# Build and start
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8080` and SMTP at `localhost:25`.
|
||||
|
||||
#### 2. Using docker build directly
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t happydeliver:latest .
|
||||
|
||||
# Run the container
|
||||
docker run -d \
|
||||
--name happydeliver \
|
||||
-p 25:25 \
|
||||
-p 8080:8080 \
|
||||
-e HAPPYDELIVER_DOMAIN=yourdomain.com \
|
||||
-e HOSTNAME=mail.yourdomain.com \
|
||||
-v $(pwd)/data:/var/lib/happydeliver \
|
||||
-v $(pwd)/logs:/var/log/happydeliver \
|
||||
happydeliver:latest
|
||||
```
|
||||
|
||||
### Manual Build
|
||||
|
||||
#### 1. Build
|
||||
|
||||
```bash
|
||||
go generate
|
||||
go build -o happyDeliver ./cmd/happyDeliver
|
||||
```
|
||||
|
||||
### 2. Run the API Server
|
||||
|
||||
```bash
|
||||
./happyDeliver server
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:8080` by default.
|
||||
|
||||
#### 3. Integrate with your existing e-mail setup
|
||||
|
||||
It is expected your setup annotate the email with eg. opendkim, spamassassin, ...
|
||||
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
|
||||
|
||||
Choose one of the following way to integrate happyDeliver in your existing setup:
|
||||
|
||||
#### Postfix LMTP Transport
|
||||
|
||||
You'll obtain the best results with a custom [transport rule](https://www.postfix.org/transport.5.html) using LMTP.
|
||||
|
||||
1. Start the happyDeliver server with LMTP enabled (default listens on `127.0.0.1:2525`):
|
||||
|
||||
```bash
|
||||
./happyDeliver server
|
||||
```
|
||||
|
||||
You can customize the LMTP address with the `-lmtp-addr` flag or in the config file.
|
||||
|
||||
2. Create the file `/etc/postfix/transport_happydeliver` with the following content:
|
||||
|
||||
```
|
||||
# Transport map - route test emails to happyDeliver LMTP server
|
||||
# Pattern: test-<uuid>@yourdomain.com -> LMTP on localhost:2525
|
||||
|
||||
/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
|
||||
```
|
||||
|
||||
3. Append the created file to `transport_maps` in your `main.cf`:
|
||||
|
||||
```diff
|
||||
-transport_maps = texthash:/etc/postfix/transport
|
||||
+transport_maps = texthash:/etc/postfix/transport, pcre:/etc/postfix/transport_happydeliver
|
||||
```
|
||||
|
||||
If your `transport_maps` option is not set, just append this line:
|
||||
|
||||
```
|
||||
transport_maps = pcre:/etc/postfix/transport_happydeliver
|
||||
```
|
||||
|
||||
Note: to use the `pcre:` type, you need to have `postfix-pcre` installed.
|
||||
|
||||
4. Reload Postfix configuration:
|
||||
|
||||
```bash
|
||||
postfix reload
|
||||
```
|
||||
|
||||
#### 4. Create a Test
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/test
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "test-550e8400@localhost",
|
||||
"status": "pending",
|
||||
"message": "Send your test email to the address above"
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Send Test Email
|
||||
|
||||
Send a test email to the address provided (you'll need to configure your MTA to route emails to the analyzer - see MTA Integration below).
|
||||
|
||||
#### 6. Get Report
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/report/550e8400-e29b-41d4-a716-446655440000
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/test` | POST | Create a new deliverability test |
|
||||
| `/api/test/{id}` | GET | Get test metadata and status |
|
||||
| `/api/report/{id}` | GET | Get detailed analysis report |
|
||||
| `/api/report/{id}/raw` | GET | Get raw annotated email |
|
||||
| `/api/status` | GET | Service health and status |
|
||||
|
||||
## Email Analyzer (CLI Mode)
|
||||
|
||||
For manual testing or debugging, you can analyze emails from the command line:
|
||||
|
||||
```bash
|
||||
cat email.eml | ./happyDeliver analyze
|
||||
```
|
||||
|
||||
Or specify recipient explicitly:
|
||||
|
||||
```bash
|
||||
cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
|
||||
```
|
||||
|
||||
**Note:** In production, emails are delivered via LMTP (see integration instructions above).
|
||||
|
||||
## Scoring System
|
||||
|
||||
The deliverability score is calculated from 0 to 10 based on:
|
||||
|
||||
- **Authentication (3 pts)**: SPF, DKIM, DMARC validation
|
||||
- **Spam (2 pts)**: SpamAssassin score
|
||||
- **Blacklist (2 pts)**: RBL/DNSBL checks
|
||||
- **Content (2 pts)**: HTML quality, links, images, unsubscribe
|
||||
- **Headers (1 pt)**: Required headers, MIME structure
|
||||
|
||||
**Ratings:**
|
||||
- 9-10: Excellent
|
||||
- 7-8.9: Good
|
||||
- 5-6.9: Fair
|
||||
- 3-4.9: Poor
|
||||
- 0-2.9: Critical
|
||||
|
||||
## Funding
|
||||
|
||||
This project is funded through [NGI Zero Core](https://nlnet.nl/core), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/happyDomain).
|
||||
|
||||
[<img src="https://nlnet.nl/logo/banner.png" alt="NLnet foundation logo" width="20%" />](https://nlnet.nl)
|
||||
[<img src="https://nlnet.nl/image/logos/NGI0_tag.svg" alt="NGI Zero Logo" width="20%" />](https://nlnet.nl/core)
|
||||
|
||||
## License
|
||||
|
||||
GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
|
||||
|
|
@ -31,11 +31,11 @@ paths:
|
|||
tags:
|
||||
- tests
|
||||
summary: Create a new deliverability test
|
||||
description: Generates a unique test email address for sending test emails
|
||||
description: Generates a unique test email address for sending test emails. No database record is created until an email is received.
|
||||
operationId: createTest
|
||||
responses:
|
||||
'201':
|
||||
description: Test created successfully
|
||||
description: Test email address generated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
|
@ -51,8 +51,8 @@ paths:
|
|||
get:
|
||||
tags:
|
||||
- tests
|
||||
summary: Get test metadata
|
||||
description: Retrieve test status and metadata
|
||||
summary: Get test status
|
||||
description: Check if a report exists for the given test ID. Returns pending if no report exists, analyzed if a report is available.
|
||||
operationId: getTest
|
||||
parameters:
|
||||
- name: id
|
||||
|
|
@ -63,13 +63,13 @@ paths:
|
|||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Test metadata retrieved successfully
|
||||
description: Test status retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Test'
|
||||
'404':
|
||||
description: Test not found
|
||||
'500':
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
|
@ -168,8 +168,8 @@ components:
|
|||
example: "test-550e8400@example.com"
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, received, analyzed, failed]
|
||||
description: Current test status
|
||||
enum: [pending, analyzed]
|
||||
description: Current test status (pending = no report yet, analyzed = report available)
|
||||
example: "analyzed"
|
||||
created_at:
|
||||
type: string
|
||||
|
|
|
|||
|
|
@ -22,31 +22,39 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/app"
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Mail Tester - Email Deliverability Testing Platform")
|
||||
fmt.Println("Version: 0.1.0-dev")
|
||||
const version = "0.1.0-dev"
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
func main() {
|
||||
fmt.Println("happyDeliver - Email Deliverability Testing Platform")
|
||||
fmt.Printf("Version: %s\n", version)
|
||||
|
||||
cfg, err := config.ConsolidateConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
|
||||
command := os.Args[1]
|
||||
command := flag.Arg(0)
|
||||
|
||||
switch command {
|
||||
case "server":
|
||||
log.Println("Starting API server...")
|
||||
// TODO: Start API server
|
||||
if err := app.RunServer(cfg); err != nil {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
case "analyze":
|
||||
log.Println("Starting email analyzer...")
|
||||
// TODO: Start email analyzer (LMTP/pipe mode)
|
||||
if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil {
|
||||
log.Fatalf("Analyzer error: %v", err)
|
||||
}
|
||||
case "version":
|
||||
fmt.Println("0.1.0-dev")
|
||||
fmt.Println(version)
|
||||
default:
|
||||
fmt.Printf("Unknown command: %s\n", command)
|
||||
printUsage()
|
||||
|
|
@ -55,8 +63,10 @@ func main() {
|
|||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("\nUsage:")
|
||||
fmt.Println(" mailtester server - Start the API server")
|
||||
fmt.Println(" mailtester analyze - Start the email analyzer (MDA mode)")
|
||||
fmt.Println(" mailtester version - Print version information")
|
||||
fmt.Println("\nCommand availables:")
|
||||
fmt.Println(" happyDeliver server - Start the API server")
|
||||
fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal")
|
||||
fmt.Println(" happyDeliver version - Print version information")
|
||||
fmt.Println("")
|
||||
flag.Usage()
|
||||
}
|
||||
|
|
|
|||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
services:
|
||||
happydeliver:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: happydeliver:latest
|
||||
container_name: happydeliver
|
||||
hostname: mail.happydeliver.local
|
||||
|
||||
environment:
|
||||
# Set your domain and hostname
|
||||
DOMAIN: happydeliver.local
|
||||
HOSTNAME: mail.happydeliver.local
|
||||
|
||||
ports:
|
||||
# SMTP port
|
||||
- "25:25"
|
||||
# API port
|
||||
- "8080:8080"
|
||||
|
||||
volumes:
|
||||
# Persistent database storage
|
||||
- ./data:/var/lib/happydeliver
|
||||
# Log files
|
||||
- ./logs:/var/log/happydeliver
|
||||
# Optional: Override config
|
||||
# - ./custom-config.yaml:/etc/happydeliver/config.yaml
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/api/status"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
data:
|
||||
logs:
|
||||
164
docker/README.md
Normal file
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"
|
||||
31
go.mod
31
go.mod
|
|
@ -3,20 +3,24 @@ module git.happydns.org/happyDeliver
|
|||
go 1.24.6
|
||||
|
||||
require (
|
||||
github.com/getkin/kin-openapi v0.132.0
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/oapi-codegen/runtime v1.1.2
|
||||
golang.org/x/net v0.42.0
|
||||
golang.org/x/net v0.45.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/getkin/kin-openapi v0.132.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
|
|
@ -25,12 +29,19 @@ require (
|
|||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
|
|
@ -40,7 +51,7 @@ require (
|
|||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/quic-go/quic-go v0.54.1 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
|
|
@ -48,12 +59,12 @@ require (
|
|||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
|
|
|||
63
go.sum
63
go.sum
|
|
@ -1,7 +1,3 @@
|
|||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
|
|
@ -17,6 +13,10 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
|
|
@ -68,11 +68,22 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
|
|
@ -88,6 +99,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
|
|||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
|
@ -126,8 +139,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
|
||||
github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
|
|
@ -136,13 +149,13 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g
|
|||
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
|
||||
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
|
|
@ -162,11 +175,11 @@ golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
|||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
|
|
@ -174,13 +187,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
|||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
|
@ -196,21 +209,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
@ -242,3 +255,9 @@ gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
|
|
|||
87
internal/analyzer/analyzer.go
Normal file
87
internal/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)
|
||||
}
|
||||
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
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// PtrTo returns a pointer to the provided value
|
||||
func PtrTo[T any](v T) *T {
|
||||
return &v
|
||||
|
|
|
|||
108
internal/app/cleanup.go
Normal file
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/analyzer"
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
)
|
||||
|
||||
// RunAnalyzer runs the standalone email analyzer (from stdin)
|
||||
func RunAnalyzer(cfg *config.Config, args []string, reader io.Reader, writer io.Writer) error {
|
||||
// Parse command-line flags
|
||||
fs := flag.NewFlagSet("analyze", flag.ExitOnError)
|
||||
jsonOutput := fs.Bool("json", false, "Output results as JSON")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Email analyzer ready, reading from stdin...")
|
||||
|
||||
// Read email from stdin
|
||||
emailData, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read email from stdin: %w", err)
|
||||
}
|
||||
|
||||
// Create analyzer with configuration
|
||||
emailAnalyzer := analyzer.NewEmailAnalyzer(cfg)
|
||||
|
||||
// Analyze the email (using a dummy test ID for standalone mode)
|
||||
result, err := emailAnalyzer.AnalyzeEmailBytes(emailData, uuid.New())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to analyze email: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Analyzing email from: %s", result.Email.From)
|
||||
|
||||
// Output results
|
||||
if *jsonOutput {
|
||||
return outputJSON(result, writer)
|
||||
}
|
||||
return outputHumanReadable(result, emailAnalyzer, writer)
|
||||
}
|
||||
|
||||
// outputJSON outputs the report as JSON
|
||||
func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error {
|
||||
reportJSON, err := json.MarshalIndent(result.Report, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal report: %w", err)
|
||||
}
|
||||
fmt.Fprintln(writer, string(reportJSON))
|
||||
return nil
|
||||
}
|
||||
|
||||
// outputHumanReadable outputs a human-readable summary
|
||||
func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error {
|
||||
// Header
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
|
||||
fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT")
|
||||
fmt.Fprintln(writer, strings.Repeat("=", 70))
|
||||
|
||||
// Score summary
|
||||
summary := emailAnalyzer.GetScoreSummaryText(result)
|
||||
fmt.Fprintln(writer, summary)
|
||||
|
||||
// Detailed checks
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
|
||||
fmt.Fprintln(writer, "DETAILED CHECK RESULTS")
|
||||
fmt.Fprintln(writer, strings.Repeat("-", 70))
|
||||
|
||||
// Group checks by category
|
||||
categories := make(map[api.CheckCategory][]api.Check)
|
||||
for _, check := range result.Report.Checks {
|
||||
categories[check.Category] = append(categories[check.Category], check)
|
||||
}
|
||||
|
||||
// Print checks by category
|
||||
categoryOrder := []api.CheckCategory{
|
||||
api.Authentication,
|
||||
api.Dns,
|
||||
api.Blacklist,
|
||||
api.Content,
|
||||
api.Headers,
|
||||
}
|
||||
|
||||
for _, category := range categoryOrder {
|
||||
checks, ok := categories[category]
|
||||
if !ok || len(checks) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(writer, "\n%s:\n", category)
|
||||
for _, check := range checks {
|
||||
statusSymbol := "✓"
|
||||
if check.Status == api.CheckStatusFail {
|
||||
statusSymbol = "✗"
|
||||
} else if check.Status == api.CheckStatusWarn {
|
||||
statusSymbol = "⚠"
|
||||
}
|
||||
|
||||
fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message)
|
||||
if check.Advice != nil && *check.Advice != "" {
|
||||
fmt.Fprintf(writer, " → %s\n", *check.Advice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70))
|
||||
return nil
|
||||
}
|
||||
89
internal/app/server.go
Normal file
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
|
||||
}
|
||||
50
internal/config/cli.go
Normal file
50
internal/config/cli.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
)
|
||||
|
||||
// declareFlags registers flags for the structure Options.
|
||||
func declareFlags(o *Config) {
|
||||
flag.StringVar(&o.DevProxy, "dev", o.DevProxy, "Proxify traffic to this host for static assets")
|
||||
flag.StringVar(&o.Bind, "bind", o.Bind, "Bind port/socket")
|
||||
flag.StringVar(&o.Database.Type, "database-type", o.Database.Type, "Select the database type between sqlite, postgres")
|
||||
flag.StringVar(&o.Database.DSN, "database-dsn", o.Database.DSN, "Database DSN or path")
|
||||
flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
|
||||
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
|
||||
flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address")
|
||||
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
|
||||
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
|
||||
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
|
||||
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
|
||||
|
||||
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||
}
|
||||
|
||||
// parseCLI parse the flags and treats extra args as configuration filename.
|
||||
func parseCLI(o *Config) error {
|
||||
flag.Parse()
|
||||
|
||||
return nil
|
||||
}
|
||||
180
internal/config/config.go
Normal file
180
internal/config/config.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
DevProxy string
|
||||
Bind string
|
||||
Database DatabaseConfig
|
||||
Email EmailConfig
|
||||
Analysis AnalysisConfig
|
||||
ReportRetention time.Duration // How long to keep reports. 0 = keep forever
|
||||
}
|
||||
|
||||
// DatabaseConfig contains database connection settings
|
||||
type DatabaseConfig struct {
|
||||
Type string
|
||||
DSN string
|
||||
}
|
||||
|
||||
// EmailConfig contains email domain and routing settings
|
||||
type EmailConfig struct {
|
||||
Domain string
|
||||
TestAddressPrefix string
|
||||
LMTPAddr string
|
||||
}
|
||||
|
||||
// AnalysisConfig contains timeout and behavior settings for email analysis
|
||||
type AnalysisConfig struct {
|
||||
DNSTimeout time.Duration
|
||||
HTTPTimeout time.Duration
|
||||
RBLs []string
|
||||
}
|
||||
|
||||
// DefaultConfig returns a configuration with sensible defaults
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
DevProxy: "",
|
||||
Bind: ":8081",
|
||||
ReportRetention: 0, // Keep reports forever by default
|
||||
Database: DatabaseConfig{
|
||||
Type: "sqlite",
|
||||
DSN: "happydeliver.db",
|
||||
},
|
||||
Email: EmailConfig{
|
||||
Domain: "happydeliver.local",
|
||||
TestAddressPrefix: "test-",
|
||||
LMTPAddr: "127.0.0.1:2525",
|
||||
},
|
||||
Analysis: AnalysisConfig{
|
||||
DNSTimeout: 5 * time.Second,
|
||||
HTTPTimeout: 10 * time.Second,
|
||||
RBLs: []string{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ConsolidateConfig fills an Options struct by reading configuration from
|
||||
// config files, environment, then command line.
|
||||
//
|
||||
// Should be called only one time.
|
||||
func ConsolidateConfig() (opts *Config, err error) {
|
||||
// Define defaults options
|
||||
opts = DefaultConfig()
|
||||
|
||||
declareFlags(opts)
|
||||
|
||||
// Establish a list of possible configuration file locations
|
||||
configLocations := []string{
|
||||
"happydeliver.conf",
|
||||
}
|
||||
|
||||
if home, err := os.UserConfigDir(); err == nil {
|
||||
configLocations = append(
|
||||
configLocations,
|
||||
path.Join(home, "happydeliver", "happydeliver.conf"),
|
||||
path.Join(home, "happydomain", "happydeliver.conf"),
|
||||
)
|
||||
}
|
||||
|
||||
configLocations = append(configLocations, path.Join("etc", "happydeliver.conf"))
|
||||
|
||||
// If config file exists, read configuration from it
|
||||
for _, filename := range configLocations {
|
||||
if _, e := os.Stat(filename); !os.IsNotExist(e) {
|
||||
log.Printf("Loading configuration from %s\n", filename)
|
||||
err = parseFile(opts, filename)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Then, overwrite that by what is present in the environment
|
||||
err = parseEnvironmentVariables(opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Finaly, command line takes precedence
|
||||
err = parseCLI(opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid
|
||||
func (c *Config) Validate() error {
|
||||
if c.Email.Domain == "" {
|
||||
return fmt.Errorf("email domain cannot be empty")
|
||||
}
|
||||
|
||||
if _, err := openapi_types.Email(fmt.Sprintf("%s1234-5678-9090@%s", c.Email.TestAddressPrefix, c.Email.Domain)).MarshalJSON(); err != nil {
|
||||
return fmt.Errorf("invalid email domain: %w", err)
|
||||
}
|
||||
|
||||
if c.Database.Type != "sqlite" && c.Database.Type != "postgres" {
|
||||
return fmt.Errorf("unsupported database type: %s", c.Database.Type)
|
||||
}
|
||||
|
||||
if c.Database.DSN == "" {
|
||||
return fmt.Errorf("database DSN cannot be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseLine treats a config line and place the read value in the variable
|
||||
// declared to the corresponding flag.
|
||||
func parseLine(o *Config, line string) (err error) {
|
||||
fields := strings.SplitN(line, "=", 2)
|
||||
orig_key := strings.TrimSpace(fields[0])
|
||||
value := strings.TrimSpace(fields[1])
|
||||
|
||||
if len(value) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimPrefix(strings.TrimPrefix(orig_key, "HAPPYDELIVER_"), "HAPPYDOMAIN_")
|
||||
key = strings.Replace(key, "_", "-", -1)
|
||||
key = strings.ToLower(key)
|
||||
|
||||
err = flag.Set(key, value)
|
||||
|
||||
return
|
||||
}
|
||||
45
internal/config/custom.go
Normal file
45
internal/config/custom.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StringArray struct {
|
||||
Array *[]string
|
||||
}
|
||||
|
||||
func (i *StringArray) String() string {
|
||||
if i.Array == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", *i.Array)
|
||||
}
|
||||
|
||||
func (i *StringArray) Set(value string) error {
|
||||
*i.Array = append(*i.Array, strings.Split(value, ",")...)
|
||||
|
||||
return nil
|
||||
}
|
||||
42
internal/config/env.go
Normal file
42
internal/config/env.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseEnvironmentVariables analyzes all the environment variables to find
|
||||
// each one starting by HAPPYDELIVER_
|
||||
func parseEnvironmentVariables(o *Config) (err error) {
|
||||
for _, line := range os.Environ() {
|
||||
if strings.HasPrefix(line, "HAPPYDELIVER_") || strings.HasPrefix(line, "HAPPYDOMAIN_") {
|
||||
err := parseLine(o, line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error in environment (%q): %w", line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
54
internal/config/file.go
Normal file
54
internal/config/file.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseFile opens the file at the given filename path, then treat each line
|
||||
// not starting with '#' as a configuration statement.
|
||||
func parseFile(o *Config, filename string) error {
|
||||
fp, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fp.Close()
|
||||
|
||||
scanner := bufio.NewScanner(fp)
|
||||
n := 0
|
||||
for scanner.Scan() {
|
||||
n += 1
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
err := parseLine(o, line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
144
internal/lmtp/server.go
Normal file
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/analyzer"
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/internal/storage"
|
||||
)
|
||||
|
||||
// EmailReceiver handles incoming emails from the MTA
|
||||
type EmailReceiver struct {
|
||||
storage storage.Storage
|
||||
config *config.Config
|
||||
analyzer *analyzer.EmailAnalyzer
|
||||
}
|
||||
|
||||
// NewEmailReceiver creates a new email receiver
|
||||
func NewEmailReceiver(store storage.Storage, cfg *config.Config) *EmailReceiver {
|
||||
return &EmailReceiver{
|
||||
storage: store,
|
||||
config: cfg,
|
||||
analyzer: analyzer.NewEmailAnalyzer(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessEmail reads an email from the reader, analyzes it, and stores the results
|
||||
func (r *EmailReceiver) ProcessEmail(emailData io.Reader, recipientEmail string) error {
|
||||
// Read the entire email
|
||||
rawEmail, err := io.ReadAll(emailData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read email: %w", err)
|
||||
}
|
||||
|
||||
return r.ProcessEmailBytes(rawEmail, recipientEmail)
|
||||
}
|
||||
|
||||
// ProcessEmailBytes processes an email from a byte slice
|
||||
func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string) error {
|
||||
|
||||
log.Printf("Received email for %s (%d bytes)", recipientEmail, len(rawEmail))
|
||||
|
||||
// Extract test ID from recipient email address
|
||||
testID, err := r.extractTestID(recipientEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract test ID: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Extracted test ID: %s", testID)
|
||||
|
||||
// Check if a report already exists for this test ID
|
||||
reportExists, err := r.storage.ReportExists(testID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check report existence: %w", err)
|
||||
}
|
||||
|
||||
if reportExists {
|
||||
log.Printf("Report already exists for test %s, skipping analysis", testID)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("Analyzing email for test %s", testID)
|
||||
|
||||
// Analyze the email using the shared analyzer
|
||||
result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to analyze email: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score)
|
||||
|
||||
// Marshal report to JSON
|
||||
reportJSON, err := json.Marshal(result.Report)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal report: %w", err)
|
||||
}
|
||||
|
||||
// Store the report
|
||||
if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil {
|
||||
return fmt.Errorf("failed to store report: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Report stored successfully for test %s", testID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTestID extracts the UUID from the test email address
|
||||
// Expected format: test-<uuid>@domain.com
|
||||
func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
|
||||
// Remove angle brackets if present (e.g., <test-uuid@domain.com>)
|
||||
email = strings.Trim(email, "<>")
|
||||
|
||||
// Extract the local part (before @)
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) != 2 {
|
||||
return uuid.Nil, fmt.Errorf("invalid email format: %s", email)
|
||||
}
|
||||
|
||||
localPart := parts[0]
|
||||
|
||||
// Remove the prefix (e.g., "test-")
|
||||
if !strings.HasPrefix(localPart, r.config.Email.TestAddressPrefix) {
|
||||
return uuid.Nil, fmt.Errorf("email does not have expected prefix: %s", email)
|
||||
}
|
||||
|
||||
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
|
||||
|
||||
// Parse UUID
|
||||
testID, err := uuid.Parse(uuidStr)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr)
|
||||
}
|
||||
|
||||
return testID, nil
|
||||
}
|
||||
|
||||
// ExtractRecipientFromHeaders attempts to extract the recipient email from email headers
|
||||
// This is useful when the email is piped and we need to determine the recipient
|
||||
func ExtractRecipientFromHeaders(rawEmail []byte) (string, error) {
|
||||
emailStr := string(rawEmail)
|
||||
|
||||
// Look for common recipient headers
|
||||
headerPatterns := []string{
|
||||
`(?i)^To:\s*(.+)$`,
|
||||
`(?i)^X-Original-To:\s*(.+)$`,
|
||||
`(?i)^Delivered-To:\s*(.+)$`,
|
||||
`(?i)^Envelope-To:\s*(.+)$`,
|
||||
}
|
||||
|
||||
for _, pattern := range headerPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(emailStr)
|
||||
if len(matches) > 1 {
|
||||
recipient := strings.TrimSpace(matches[1])
|
||||
// Clean up the email address
|
||||
recipient = strings.Trim(recipient, "<>")
|
||||
// Take only the first email if there are multiple
|
||||
if idx := strings.Index(recipient, ","); idx != -1 {
|
||||
recipient = recipient[:idx]
|
||||
}
|
||||
if recipient != "" {
|
||||
return recipient, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not extract recipient from email headers")
|
||||
}
|
||||
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()
|
||||
}
|
||||
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: "./src/lib/hey-api.ts",
|
||||
},
|
||||
],
|
||||
});
|
||||
5312
web/package-lock.json
generated
Normal file
5312
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>
|
||||
216
web/src/routes/+page.svelte
Normal file
216
web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { createTest as apiCreateTest } from "$lib/api";
|
||||
import { FeatureCard, HowItWorksStep } from "$lib/components";
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function createTest() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await apiCreateTest();
|
||||
if (response.data) {
|
||||
goto(`/test/${response.data.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to create test";
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: "bi-shield-check",
|
||||
title: "Authentication",
|
||||
description:
|
||||
"SPF, DKIM, and DMARC validation with detailed results and recommendations.",
|
||||
variant: "primary" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-globe",
|
||||
title: "DNS Records",
|
||||
description: "Verify MX, SPF, DKIM, and DMARC records are properly configured.",
|
||||
variant: "success" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-bug",
|
||||
title: "Spam Score",
|
||||
description: "SpamAssassin analysis with detailed test results and scoring.",
|
||||
variant: "warning" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-list-check",
|
||||
title: "Blacklists",
|
||||
description: "Check if your IP is listed in major DNS-based blacklists (RBLs).",
|
||||
variant: "danger" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-file-text",
|
||||
title: "Content Analysis",
|
||||
description: "HTML structure, link validation, image analysis, and more.",
|
||||
variant: "info" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-card-heading",
|
||||
title: "Header Quality",
|
||||
description: "Validate required headers, check for missing fields and alignment.",
|
||||
variant: "secondary" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-bar-chart",
|
||||
title: "Detailed Scoring",
|
||||
description:
|
||||
"0-10 deliverability score with breakdown by category and recommendations.",
|
||||
variant: "primary" as const,
|
||||
},
|
||||
{
|
||||
icon: "bi-lock",
|
||||
title: "Privacy First",
|
||||
description: "Self-hosted solution, your data never leaves your infrastructure.",
|
||||
variant: "success" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{
|
||||
step: 1,
|
||||
title: "Create Test",
|
||||
description: "Click the button to generate a unique test email address.",
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: "Send Email",
|
||||
description: "Send a test email from your mail server to the provided address.",
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: "View Results",
|
||||
description: "Get instant detailed analysis with actionable recommendations.",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>happyDeliver - Email Deliverability Testing</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="hero py-5">
|
||||
<div class="container py-5">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-lg-8 mx-auto text-center fade-in">
|
||||
<h1 class="display-3 fw-bold mb-4">Test Your Email Deliverability</h1>
|
||||
<p class="lead mb-4 opacity-90">
|
||||
Get detailed insights into your email configuration, authentication, spam score,
|
||||
and more. Open-source, self-hosted, and privacy-focused.
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-light btn-lg px-5 py-3 shadow"
|
||||
onclick={createTest}
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Creating Test...
|
||||
{:else}
|
||||
<i class="bi bi-envelope-plus me-2"></i>
|
||||
Start Free Test
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger mt-4 d-inline-block" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section class="py-5">
|
||||
<div class="container py-4">
|
||||
<div class="row text-center mb-5">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<h2 class="display-5 fw-bold mb-3">Comprehensive Email Analysis</h2>
|
||||
<p class="text-muted">
|
||||
Your favorite deliverability tester, open-source and
|
||||
self-hostable for complete privacy and control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
{#each features as feature}
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<FeatureCard {...feature} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section class="bg-light py-5">
|
||||
<div class="container py-4">
|
||||
<div class="row text-center mb-5">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<h2 class="display-5 fw-bold mb-3">How It Works</h2>
|
||||
<p class="text-muted">
|
||||
Simple three-step process to test your email deliverability
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
{#each steps as stepData}
|
||||
<div class="col-md-4">
|
||||
<HowItWorksStep {...stepData} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-5">
|
||||
<button
|
||||
class="btn btn-primary btn-lg px-5 py-3"
|
||||
onclick={createTest}
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Creating Test...
|
||||
{:else}
|
||||
<i class="bi bi-rocket-takeoff me-2"></i>
|
||||
Get Started Now
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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