Create test on email arrival
This commit is contained in:
parent
f8e6a2f314
commit
bb1dd2a85e
6 changed files with 46 additions and 155 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,39 +28,12 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue