Create test on email arrival

This commit is contained in:
nemunaire 2025-10-18 11:56:22 +07:00
commit bb1dd2a85e
6 changed files with 46 additions and 155 deletions

View file

@ -31,11 +31,11 @@ paths:
tags: tags:
- tests - tests
summary: Create a new deliverability test summary: Create a new deliverability test
description: Generates a unique test email address for sending test emails description: Generates a unique test email address for sending test emails. No database record is created until an email is received.
operationId: createTest operationId: createTest
responses: responses:
'201': '201':
description: Test created successfully description: Test email address generated successfully
content: content:
application/json: application/json:
schema: schema:
@ -51,8 +51,8 @@ paths:
get: get:
tags: tags:
- tests - tests
summary: Get test metadata summary: Get test status
description: Retrieve test status and metadata description: Check if a report exists for the given test ID. Returns pending if no report exists, analyzed if a report is available.
operationId: getTest operationId: getTest
parameters: parameters:
- name: id - name: id
@ -63,13 +63,13 @@ paths:
format: uuid format: uuid
responses: responses:
'200': '200':
description: Test metadata retrieved successfully description: Test status retrieved successfully
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Test' $ref: '#/components/schemas/Test'
'404': '500':
description: Test not found description: Internal server error
content: content:
application/json: application/json:
schema: schema:
@ -168,8 +168,8 @@ components:
example: "test-550e8400@example.com" example: "test-550e8400@example.com"
status: status:
type: string type: string
enum: [pending, received, analyzed, failed] enum: [pending, analyzed]
description: Current test status description: Current test status (pending = no report yet, analyzed = report available)
example: "analyzed" example: "analyzed"
created_at: created_at:
type: string type: string

View file

@ -53,7 +53,7 @@ func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler {
// CreateTest creates a new deliverability test // CreateTest creates a new deliverability test
// (POST /test) // (POST /test)
func (h *APIHandler) CreateTest(c *gin.Context) { func (h *APIHandler) CreateTest(c *gin.Context) {
// Generate a unique test ID // Generate a unique test ID (no database record created)
testID := uuid.New() testID := uuid.New()
// Generate test email address // Generate test email address
@ -63,20 +63,9 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
h.config.Email.Domain, 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 // Return response
c.JSON(http.StatusCreated, TestResponse{ c.JSON(http.StatusCreated, TestResponse{
Id: test.ID, Id: testID,
Email: openapi_types.Email(email), Email: openapi_types.Email(email),
Status: TestResponseStatusPending, Status: TestResponseStatusPending,
Message: stringPtr("Send your test email to the address above"), Message: stringPtr("Send your test email to the address above"),
@ -86,51 +75,41 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
// GetTest retrieves test metadata // GetTest retrieves test metadata
// (GET /test/{id}) // (GET /test/{id})
func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) { func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
test, err := h.storage.GetTest(id) // Check if a report exists for this test ID
reportExists, err := h.storage.ReportExists(id)
if err != nil { 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{ c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error", Error: "internal_error",
Message: "Failed to retrieve test", Message: "Failed to check test status",
Details: stringPtr(err.Error()), Details: stringPtr(err.Error()),
}) })
return return
} }
// Convert storage status to API status // Determine status based on report existence
var apiStatus TestStatus var apiStatus TestStatus
switch test.Status { if reportExists {
case storage.StatusPending:
apiStatus = TestStatusPending
case storage.StatusReceived:
apiStatus = TestStatusReceived
case storage.StatusAnalyzed:
apiStatus = TestStatusAnalyzed apiStatus = TestStatusAnalyzed
case storage.StatusFailed: } else {
apiStatus = TestStatusFailed
default:
apiStatus = TestStatusPending apiStatus = TestStatusPending
} }
// Generate test email address // Generate test email address
email := fmt.Sprintf("%s%s@%s", email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix, h.config.Email.TestAddressPrefix,
test.ID.String(), id.String(),
h.config.Email.Domain, 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{ c.JSON(http.StatusOK, Test{
Id: test.ID, Id: id,
Email: openapi_types.Email(email), Email: openapi_types.Email(email),
Status: apiStatus, Status: apiStatus,
CreatedAt: test.CreatedAt, CreatedAt: now,
UpdatedAt: &test.UpdatedAt, UpdatedAt: &now,
}) })
} }
@ -187,9 +166,9 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
// Calculate uptime // Calculate uptime
uptime := int(time.Since(h.startTime).Seconds()) uptime := int(time.Since(h.startTime).Seconds())
// Check database connectivity // Check database connectivity by trying to check if a report exists
dbStatus := StatusComponentsDatabaseUp dbStatus := StatusComponentsDatabaseUp
if _, err := h.storage.GetTest(uuid.New()); err != nil && err != storage.ErrNotFound { if _, err := h.storage.ReportExists(uuid.New()); err != nil {
dbStatus = StatusComponentsDatabaseDown dbStatus = StatusComponentsDatabaseDown
} }

View file

@ -76,19 +76,15 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
log.Printf("Extracted test ID: %s", testID) log.Printf("Extracted test ID: %s", testID)
// Verify test exists and is in pending status // Check if a report already exists for this test ID
test, err := r.storage.GetTest(testID) reportExists, err := r.storage.ReportExists(testID)
if err != nil { if err != nil {
return fmt.Errorf("test not found: %w", err) return fmt.Errorf("failed to check report existence: %w", err)
} }
if test.Status != storage.StatusPending { if reportExists {
return fmt.Errorf("test is not in pending status (current: %s)", test.Status) log.Printf("Report already exists for test %s, skipping analysis", testID)
} return nil
// 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) log.Printf("Analyzing email for test %s", testID)
@ -96,10 +92,6 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
// Analyze the email using the shared analyzer // Analyze the email using the shared analyzer
result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID) result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID)
if err != nil { 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 analyze email: %w", err) return fmt.Errorf("failed to analyze email: %w", err)
} }
@ -108,19 +100,11 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
// Marshal report to JSON // Marshal report to JSON
reportJSON, err := json.Marshal(result.Report) reportJSON, err := json.Marshal(result.Report)
if err != nil { 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) return fmt.Errorf("failed to marshal report: %w", err)
} }
// Store the report // Store the report
if _, err := r.storage.CreateReport(testID, rawEmail, reportJSON); err != nil { 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) return fmt.Errorf("failed to store report: %w", err)
} }

View file

@ -28,37 +28,10 @@ import (
"gorm.io/gorm" "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 // Report represents the analysis report for a test
type Report struct { type Report struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"` ID uuid.UUID `gorm:"type:uuid;primaryKey"`
TestID uuid.UUID `gorm:"type:uuid;uniqueIndex;not null"` 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 RawEmail []byte `gorm:"type:bytea;not null"` // Full raw email with headers
ReportJSON []byte `gorm:"type:bytea;not null"` // JSON-encoded report data ReportJSON []byte `gorm:"type:bytea;not null"` // JSON-encoded report data
CreatedAt time.Time `gorm:"not null"` CreatedAt time.Time `gorm:"not null"`

View file

@ -38,14 +38,10 @@ var (
// Storage interface defines operations for persisting and retrieving data // Storage interface defines operations for persisting and retrieving data
type Storage interface { 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 // Report operations
CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error)
GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error) GetReport(testID uuid.UUID) (reportJSON []byte, rawEmail []byte, err error)
ReportExists(testID uuid.UUID) (bool, error)
// Close closes the database connection // Close closes the database connection
Close() error Close() error
@ -75,51 +71,13 @@ func NewStorage(dbType, dsn string) (Storage, error) {
} }
// Auto-migrate the schema // Auto-migrate the schema
if err := db.AutoMigrate(&Test{}, &Report{}); err != nil { if err := db.AutoMigrate(&Report{}); err != nil {
return nil, fmt.Errorf("failed to migrate database schema: %w", err) return nil, fmt.Errorf("failed to migrate database schema: %w", err)
} }
return &DBStorage{db: db}, nil 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 // CreateReport creates a new report for a test
func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) { func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON []byte) (*Report, error) {
dbReport := &Report{ dbReport := &Report{
@ -132,14 +90,18 @@ func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON [
return nil, fmt.Errorf("failed to create report: %w", err) 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 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 // GetReport retrieves a report by test ID, returning the raw JSON and email bytes
func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) {
var dbReport Report var dbReport Report

View file

@ -30,13 +30,6 @@
transactional emails, etc.) for the most accurate results. transactional emails, etc.) for the most accurate results.
</div> </div>
{#if test.status === "received"}
<div class="alert alert-success" role="alert">
<i class="bi bi-check-circle me-2"></i>
Email received! Analyzing...
</div>
{/if}
<div class="d-flex align-items-center justify-content-center gap-2 text-muted"> <div class="d-flex align-items-center justify-content-center gap-2 text-muted">
<div class="spinner-border spinner-border-sm" role="status"></div> <div class="spinner-border spinner-border-sm" role="status"></div>
<small>Checking for email every 3 seconds...</small> <small>Checking for email every 3 seconds...</small>