Glue things together
This commit is contained in:
parent
e1356ebc22
commit
395ea2122e
9 changed files with 972 additions and 28 deletions
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