Glue things together

This commit is contained in:
nemunaire 2025-10-15 16:16:29 +07:00
commit 395ea2122e
9 changed files with 972 additions and 28 deletions

215
internal/api/handlers.go Normal file
View 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,
})
}

View file

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

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

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