diff --git a/api/openapi.yaml b/api/openapi.yaml index f027f1a..467f62c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -31,11 +31,11 @@ paths: tags: - tests 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 responses: '201': - description: Test created successfully + description: Test email address generated successfully content: application/json: schema: @@ -51,8 +51,8 @@ paths: get: tags: - tests - summary: Get test metadata - description: Retrieve test status and metadata + summary: Get test status + 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 parameters: - name: id @@ -63,13 +63,13 @@ paths: format: uuid responses: '200': - description: Test metadata retrieved successfully + description: Test status retrieved successfully content: application/json: schema: $ref: '#/components/schemas/Test' - '404': - description: Test not found + '500': + description: Internal server error content: application/json: schema: @@ -168,8 +168,8 @@ components: example: "test-550e8400@example.com" status: type: string - enum: [pending, received, analyzed, failed] - description: Current test status + enum: [pending, analyzed] + description: Current test status (pending = no report yet, analyzed = report available) example: "analyzed" created_at: type: string diff --git a/internal/api/handlers.go b/internal/api/handlers.go index ed97bc6..79d839e 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -53,7 +53,7 @@ func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler { // CreateTest creates a new deliverability test // (POST /test) func (h *APIHandler) CreateTest(c *gin.Context) { - // Generate a unique test ID + // Generate a unique test ID (no database record created) testID := uuid.New() // Generate test email address @@ -63,20 +63,9 @@ func (h *APIHandler) CreateTest(c *gin.Context) { 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, + Id: testID, Email: openapi_types.Email(email), Status: TestResponseStatusPending, 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 // (GET /test/{id}) 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 == 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", + Message: "Failed to check test status", Details: stringPtr(err.Error()), }) return } - // Convert storage status to API status + // Determine status based on report existence var apiStatus TestStatus - switch test.Status { - case storage.StatusPending: - apiStatus = TestStatusPending - case storage.StatusReceived: - apiStatus = TestStatusReceived - case storage.StatusAnalyzed: + if reportExists { apiStatus = TestStatusAnalyzed - case storage.StatusFailed: - apiStatus = TestStatusFailed - default: + } else { apiStatus = TestStatusPending } // Generate test email address email := fmt.Sprintf("%s%s@%s", h.config.Email.TestAddressPrefix, - test.ID.String(), + id.String(), 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{ - Id: test.ID, + Id: id, Email: openapi_types.Email(email), Status: apiStatus, - CreatedAt: test.CreatedAt, - UpdatedAt: &test.UpdatedAt, + CreatedAt: now, + UpdatedAt: &now, }) } @@ -187,9 +166,9 @@ func (h *APIHandler) GetStatus(c *gin.Context) { // Calculate uptime uptime := int(time.Since(h.startTime).Seconds()) - // Check database connectivity + // Check database connectivity by trying to check if a report exists 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 } diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index 325ef31..db1c2ea 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -76,19 +76,15 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string log.Printf("Extracted test ID: %s", testID) - // Verify test exists and is in pending status - test, err := r.storage.GetTest(testID) + // Check if a report already exists for this test ID + reportExists, err := r.storage.ReportExists(testID) 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 { - 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) + if reportExists { + log.Printf("Report already exists for test %s, skipping analysis", testID) + return nil } 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 result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID) 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) } @@ -108,19 +100,11 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string // Marshal report to JSON reportJSON, err := json.Marshal(result.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) } diff --git a/internal/storage/models.go b/internal/storage/models.go index 546bf2f..dbb3daa 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -28,39 +28,12 @@ import ( "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 + 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 + ReportJSON []byte `gorm:"type:bytea;not null"` // JSON-encoded report data CreatedAt time.Time `gorm:"not null"` } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ff06edc..7550463 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -38,14 +38,10 @@ var ( // 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) + ReportExists(testID uuid.UUID) (bool, error) // Close closes the database connection Close() error @@ -75,51 +71,13 @@ func NewStorage(dbType, dsn string) (Storage, error) { } // 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 &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{ @@ -132,14 +90,18 @@ func (s *DBStorage) CreateReport(testID uuid.UUID, rawEmail []byte, reportJSON [ 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 } +// 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 func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { var dbReport Report diff --git a/web/src/lib/components/PendingState.svelte b/web/src/lib/components/PendingState.svelte index a5075e8..ab9a6f8 100644 --- a/web/src/lib/components/PendingState.svelte +++ b/web/src/lib/components/PendingState.svelte @@ -30,13 +30,6 @@ transactional emails, etc.) for the most accurate results. - {#if test.status === "received"} -