Glue things together
This commit is contained in:
parent
e1356ebc22
commit
395ea2122e
9 changed files with 972 additions and 28 deletions
163
README.md
Normal file
163
README.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# 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
|
||||
- **Email Receiver**: MDA (Mail Delivery Agent) mode for processing incoming test emails
|
||||
- **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
|
||||
|
||||
### 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 Transport rule
|
||||
|
||||
You'll obtains the best results with a custom [transport tule](https://www.postfix.org/transport.5.html).
|
||||
|
||||
1. Append the following lines at the end of your `master.cf` file:
|
||||
|
||||
```diff
|
||||
+
|
||||
+# happyDeliver analyzer - receives emails matching transport_maps
|
||||
+happydeliver unix - n n - - pipe
|
||||
+ flags=DRXhu user=happydeliver argv=/path/to/happyDeliver analyze -recipient ${recipient}
|
||||
```
|
||||
|
||||
2. Create the file `/etc/postfix/transport_happyDeliver` with the following content:
|
||||
|
||||
```
|
||||
# Transport map - route test emails to happyDeliver analyzer
|
||||
# Pattern: test-<uuid>@yourdomain.com -> happydeliver pipe
|
||||
|
||||
/^test-[a-f0-9-]+@yourdomain\.com$/ happydeliver:
|
||||
```
|
||||
|
||||
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_maps
|
||||
```
|
||||
|
||||
If your `transport_maps` option is not set, just append this line:
|
||||
|
||||
```
|
||||
transport_maps = pcre:/etc/postfix/transport_maps
|
||||
```
|
||||
|
||||
Note: to use the `pcre:` type, you need to have `postfix-pcre` installed.
|
||||
|
||||
#### Postfix Aliases
|
||||
|
||||
You can dedicate an alias to the tool using your `recipient_delimiter` (most likely `+`).
|
||||
|
||||
Add the following line in your `/etc/postfix/aliases`:
|
||||
|
||||
```diff
|
||||
+test: "|/path/to/happyDeliver analyze"
|
||||
```
|
||||
|
||||
Note that the recipient address has to be present in header.
|
||||
|
||||
### 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 (MDA Mode)
|
||||
|
||||
To process an email from an MTA pipe:
|
||||
|
||||
```bash
|
||||
cat email.eml | ./happyDeliver analyze
|
||||
```
|
||||
|
||||
Or specify recipient explicitly:
|
||||
|
||||
```bash
|
||||
cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com
|
||||
```
|
||||
|
||||
## 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)
|
||||
|
|
@ -22,14 +22,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"git.happydns.org/happyDeliver/internal/config"
|
||||
"git.happydns.org/happyDeliver/internal/receiver"
|
||||
"git.happydns.org/happyDeliver/internal/storage"
|
||||
)
|
||||
|
||||
const version = "0.1.0-dev"
|
||||
|
||||
func main() {
|
||||
fmt.Println("Mail Tester - Email Deliverability Testing Platform")
|
||||
fmt.Println("Version: 0.1.0-dev")
|
||||
fmt.Println("happyDeliver - Email Deliverability Testing Platform")
|
||||
fmt.Printf("Version: %s\n", version)
|
||||
|
||||
cfg, err := config.ConsolidateConfig()
|
||||
if err != nil {
|
||||
|
|
@ -40,13 +51,11 @@ func main() {
|
|||
|
||||
switch command {
|
||||
case "server":
|
||||
log.Println("Starting API server...")
|
||||
// TODO: Start API server
|
||||
runServer(cfg)
|
||||
case "analyze":
|
||||
log.Println("Starting email analyzer...")
|
||||
// TODO: Start email analyzer (LMTP/pipe mode)
|
||||
runAnalyzer(cfg)
|
||||
case "version":
|
||||
fmt.Println("0.1.0-dev")
|
||||
fmt.Println(version)
|
||||
default:
|
||||
fmt.Printf("Unknown command: %s\n", command)
|
||||
printUsage()
|
||||
|
|
@ -54,6 +63,88 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
func runServer(cfg *config.Config) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
log.Fatalf("Invalid configuration: %v", err)
|
||||
}
|
||||
|
||||
// Initialize storage
|
||||
store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
log.Printf("Connected to %s database", cfg.Database.Type)
|
||||
|
||||
// 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)
|
||||
|
||||
// Start 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 {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runAnalyzer(cfg *config.Config) {
|
||||
// Parse command-line flags
|
||||
fs := flag.NewFlagSet("analyze", flag.ExitOnError)
|
||||
recipientEmail := fs.String("recipient", "", "Recipient email address (optional, will be extracted from headers if not provided)")
|
||||
fs.Parse(flag.Args()[1:])
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
log.Fatalf("Invalid configuration: %v", err)
|
||||
}
|
||||
|
||||
// Initialize storage
|
||||
store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
log.Printf("Email analyzer ready, reading from stdin...")
|
||||
|
||||
// Read email from stdin
|
||||
emailData, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read email from stdin: %v", err)
|
||||
}
|
||||
|
||||
// If recipient not provided, try to extract from headers
|
||||
var recipient string
|
||||
if *recipientEmail != "" {
|
||||
recipient = *recipientEmail
|
||||
} else {
|
||||
recipient, err = receiver.ExtractRecipientFromHeaders(emailData)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to extract recipient: %v", err)
|
||||
}
|
||||
log.Printf("Extracted recipient: %s", recipient)
|
||||
}
|
||||
|
||||
// Process the email
|
||||
recv := receiver.NewEmailReceiver(store, cfg)
|
||||
if err := recv.ProcessEmailBytes(emailData, recipient); err != nil {
|
||||
log.Fatalf("Failed to process email: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Email processed successfully")
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("\nCommand availables:")
|
||||
fmt.Println(" happyDeliver server - Start the API server")
|
||||
|
|
|
|||
24
go.mod
24
go.mod
|
|
@ -7,7 +7,10 @@ require (
|
|||
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 (
|
||||
|
|
@ -25,12 +28,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
|
||||
|
|
@ -48,12 +58,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
|
||||
|
|
|
|||
49
go.sum
49
go.sum
|
|
@ -68,6 +68,18 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
|
|
@ -88,6 +100,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=
|
||||
|
|
@ -143,6 +157,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
|||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
|
|
@ -162,11 +177,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 +189,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 +211,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 +257,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=
|
||||
|
|
|
|||
215
internal/api/handlers.go
Normal file
215
internal/api/handlers.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
// 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
|
||||
testID := uuid.New()
|
||||
|
||||
// Generate test email address
|
||||
email := fmt.Sprintf("%s%s@%s",
|
||||
h.config.Email.TestAddressPrefix,
|
||||
testID.String(),
|
||||
h.config.Email.Domain,
|
||||
)
|
||||
|
||||
// Create test in database
|
||||
test, err := h.storage.CreateTest(testID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, Error{
|
||||
Error: "internal_error",
|
||||
Message: "Failed to create test",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return response
|
||||
c.JSON(http.StatusCreated, TestResponse{
|
||||
Id: test.ID,
|
||||
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) {
|
||||
test, err := h.storage.GetTest(id)
|
||||
if err != nil {
|
||||
if err == storage.ErrNotFound {
|
||||
c.JSON(http.StatusNotFound, Error{
|
||||
Error: "not_found",
|
||||
Message: "Test not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, Error{
|
||||
Error: "internal_error",
|
||||
Message: "Failed to retrieve test",
|
||||
Details: stringPtr(err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert storage status to API status
|
||||
var apiStatus TestStatus
|
||||
switch test.Status {
|
||||
case storage.StatusPending:
|
||||
apiStatus = TestStatusPending
|
||||
case storage.StatusReceived:
|
||||
apiStatus = TestStatusReceived
|
||||
case storage.StatusAnalyzed:
|
||||
apiStatus = TestStatusAnalyzed
|
||||
case storage.StatusFailed:
|
||||
apiStatus = TestStatusFailed
|
||||
default:
|
||||
apiStatus = TestStatusPending
|
||||
}
|
||||
|
||||
// Generate test email address
|
||||
email := fmt.Sprintf("%s%s@%s",
|
||||
h.config.Email.TestAddressPrefix,
|
||||
test.ID.String(),
|
||||
h.config.Email.Domain,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, Test{
|
||||
Id: test.ID,
|
||||
Email: openapi_types.Email(email),
|
||||
Status: apiStatus,
|
||||
CreatedAt: test.CreatedAt,
|
||||
UpdatedAt: &test.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
dbStatus := StatusComponentsDatabaseUp
|
||||
if _, err := h.storage.GetTest(uuid.New()); err != nil && err != storage.ErrNotFound {
|
||||
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
|
||||
|
|
|
|||
204
internal/receiver/receiver.go
Normal file
204
internal/receiver/receiver.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// 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 (
|
||||
"bytes"
|
||||
"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
|
||||
}
|
||||
|
||||
// NewEmailReceiver creates a new email receiver
|
||||
func NewEmailReceiver(store storage.Storage, cfg *config.Config) *EmailReceiver {
|
||||
return &EmailReceiver{
|
||||
storage: store,
|
||||
config: 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)
|
||||
|
||||
// Verify test exists and is in pending status
|
||||
test, err := r.storage.GetTest(testID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("test not found: %w", err)
|
||||
}
|
||||
|
||||
if test.Status != storage.StatusPending {
|
||||
return fmt.Errorf("test is not in pending status (current: %s)", test.Status)
|
||||
}
|
||||
|
||||
// Update test status to received
|
||||
if err := r.storage.UpdateTestStatus(testID, storage.StatusReceived); err != nil {
|
||||
return fmt.Errorf("failed to update test status: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Analyzing email for test %s", testID)
|
||||
|
||||
// Parse the email
|
||||
emailMsg, err := analyzer.ParseEmail(bytes.NewReader(rawEmail))
|
||||
if err != nil {
|
||||
// Update test status to failed
|
||||
if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil {
|
||||
log.Printf("Failed to update test status to failed: %v", updateErr)
|
||||
}
|
||||
return fmt.Errorf("failed to parse email: %w", err)
|
||||
}
|
||||
|
||||
// Create report generator with configuration
|
||||
generator := analyzer.NewReportGenerator(
|
||||
r.config.Analysis.DNSTimeout,
|
||||
r.config.Analysis.HTTPTimeout,
|
||||
r.config.Analysis.RBLs,
|
||||
)
|
||||
|
||||
// Analyze the email
|
||||
results := generator.AnalyzeEmail(emailMsg)
|
||||
|
||||
// Generate the report
|
||||
report := generator.GenerateReport(testID, results)
|
||||
|
||||
log.Printf("Analysis complete. Score: %.2f/10", report.Score)
|
||||
|
||||
// Marshal report to JSON
|
||||
reportJSON, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
// Update test status to failed
|
||||
if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil {
|
||||
log.Printf("Failed to update test status to failed: %v", updateErr)
|
||||
}
|
||||
return fmt.Errorf("failed to marshal report: %w", err)
|
||||
}
|
||||
|
||||
// Store the report
|
||||
if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil {
|
||||
// Update test status to failed
|
||||
if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil {
|
||||
log.Printf("Failed to update test status to failed: %v", updateErr)
|
||||
}
|
||||
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")
|
||||
}
|
||||
73
internal/storage/models.go
Normal file
73
internal/storage/models.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// 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"
|
||||
)
|
||||
|
||||
// TestStatus represents the status of a deliverability test
|
||||
type TestStatus string
|
||||
|
||||
const (
|
||||
StatusPending TestStatus = "pending"
|
||||
StatusReceived TestStatus = "received"
|
||||
StatusAnalyzed TestStatus = "analyzed"
|
||||
StatusFailed TestStatus = "failed"
|
||||
)
|
||||
|
||||
// Test represents a deliverability test instance
|
||||
type Test struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
Status TestStatus `gorm:"type:varchar(20);not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
Report *Report `gorm:"foreignKey:TestID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that generates a UUID before creating a test
|
||||
func (t *Test) BeforeCreate(tx *gorm.DB) error {
|
||||
if t.ID == uuid.Nil {
|
||||
t.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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
|
||||
}
|
||||
163
internal/storage/storage.go
Normal file
163
internal/storage/storage.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// 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"
|
||||
|
||||
"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 {
|
||||
// Test operations
|
||||
CreateTest(id uuid.UUID) (*Test, error)
|
||||
GetTest(id uuid.UUID) (*Test, error)
|
||||
UpdateTestStatus(id uuid.UUID, status TestStatus) error
|
||||
|
||||
// Report operations
|
||||
CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error)
|
||||
GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err 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(&Test{}, &Report{}); err != nil {
|
||||
return nil, fmt.Errorf("failed to migrate database schema: %w", err)
|
||||
}
|
||||
|
||||
return &DBStorage{db: db}, nil
|
||||
}
|
||||
|
||||
// CreateTest creates a new test with pending status
|
||||
func (s *DBStorage) CreateTest(id uuid.UUID) (*Test, error) {
|
||||
test := &Test{
|
||||
ID: id,
|
||||
Status: StatusPending,
|
||||
}
|
||||
|
||||
if err := s.db.Create(test).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create test: %w", err)
|
||||
}
|
||||
|
||||
return test, nil
|
||||
}
|
||||
|
||||
// GetTest retrieves a test by ID
|
||||
func (s *DBStorage) GetTest(id uuid.UUID) (*Test, error) {
|
||||
var test Test
|
||||
if err := s.db.First(&test, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get test: %w", err)
|
||||
}
|
||||
return &test, nil
|
||||
}
|
||||
|
||||
// UpdateTestStatus updates the status of a test
|
||||
func (s *DBStorage) UpdateTestStatus(id uuid.UUID, status TestStatus) error {
|
||||
result := s.db.Model(&Test{}).Where("id = ?", id).Update("status", status)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to update test status: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return 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)
|
||||
}
|
||||
|
||||
// Update test status to analyzed
|
||||
if err := s.UpdateTestStatus(testID, StatusAnalyzed); err != nil {
|
||||
return nil, fmt.Errorf("failed to update test status: %w", err)
|
||||
}
|
||||
|
||||
return dbReport, 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
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (s *DBStorage) Close() error {
|
||||
sqlDB, err := s.db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue