From dfc0eeb3239a950e8cfaeb629529dfd4f2ee9dde Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 19:33:23 +0700 Subject: [PATCH] New route to perform a new analysis of the email --- api/openapi.yaml | 35 +++++++++++++ internal/api/handlers.go | 67 ++++++++++++++++++++++++- internal/app/server.go | 6 ++- internal/storage/storage.go | 13 +++++ pkg/analyzer/analyzer.go | 30 +++++++++++ web/src/routes/test/[test]/+page.svelte | 36 ++++++++++++- 6 files changed, 183 insertions(+), 4 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 8852c42..e7ca45c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -134,6 +134,41 @@ paths: schema: $ref: '#/components/schemas/Error' + /report/{id}/reanalyze: + post: + tags: + - reports + summary: Reanalyze email and regenerate report + description: Re-run the analysis on the stored raw email to regenerate the report with the latest analyzer version. This is useful after analyzer improvements or bug fixes. + operationId: reanalyzeReport + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: '^[a-z0-9-]+$' + description: Base32-encoded test ID (with hyphens) + responses: + '200': + description: Report regenerated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + '404': + description: Email not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error during reanalysis + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /status: get: tags: diff --git a/internal/api/handlers.go b/internal/api/handlers.go index b53c391..3b57747 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -35,18 +35,26 @@ import ( "git.happydns.org/happyDeliver/internal/utils" ) +// EmailAnalyzer defines the interface for email analysis +// This interface breaks the circular dependency with pkg/analyzer +type EmailAnalyzer interface { + AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error) +} + // APIHandler implements the ServerInterface for handling API requests type APIHandler struct { storage storage.Storage config *config.Config + analyzer EmailAnalyzer startTime time.Time } // NewAPIHandler creates a new API handler -func NewAPIHandler(store storage.Storage, cfg *config.Config) *APIHandler { +func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler { return &APIHandler{ storage: store, config: cfg, + analyzer: analyzer, startTime: time.Now(), } } @@ -192,6 +200,63 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { c.Data(http.StatusOK, "text/plain", rawEmail) } +// ReanalyzeReport re-analyzes an existing email and regenerates the report +// (POST /report/{id}/reanalyze) +func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { + // Convert base32 ID to UUID + testUUID, err := utils.Base32ToUUID(id) + if err != nil { + c.JSON(http.StatusBadRequest, Error{ + Error: "invalid_id", + Message: "Invalid test ID format", + Details: stringPtr(err.Error()), + }) + return + } + + // Retrieve the existing report (mainly to get the raw email) + _, rawEmail, err := h.storage.GetReport(testUUID) + 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 email", + Details: stringPtr(err.Error()), + }) + return + } + + // Re-analyze the email using the current analyzer + reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, Error{ + Error: "analysis_error", + Message: "Failed to re-analyze email", + Details: stringPtr(err.Error()), + }) + return + } + + // Update the report in storage + if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil { + c.JSON(http.StatusInternalServerError, Error{ + Error: "internal_error", + Message: "Failed to update report", + Details: stringPtr(err.Error()), + }) + return + } + + // Return the updated report JSON directly + c.Data(http.StatusOK, "application/json", reportJSON) +} + // GetStatus retrieves service health status // (GET /status) func (h *APIHandler) GetStatus(c *gin.Context) { diff --git a/internal/app/server.go b/internal/app/server.go index 332516b..0c70eef 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -32,6 +32,7 @@ import ( "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/lmtp" "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/pkg/analyzer" "git.happydns.org/happyDeliver/web" ) @@ -63,8 +64,11 @@ func RunServer(cfg *config.Config) error { } }() + // Create analyzer adapter for API + analyzerAdapter := analyzer.NewAPIAdapter(cfg) + // Create API handler - handler := api.NewAPIHandler(store, cfg) + handler := api.NewAPIHandler(store, cfg, analyzerAdapter) // Set up Gin router if os.Getenv("GIN_MODE") == "" { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7c27279..d8a8cb4 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -43,6 +43,7 @@ type Storage interface { 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) + UpdateReport(testID uuid.UUID, reportJSON []byte) error DeleteOldReports(olderThan time.Time) (int64, error) // Close closes the database connection @@ -117,6 +118,18 @@ func (s *DBStorage) GetReport(testID uuid.UUID) ([]byte, []byte, error) { return dbReport.ReportJSON, dbReport.RawEmail, nil } +// UpdateReport updates the report JSON for an existing test ID +func (s *DBStorage) UpdateReport(testID uuid.UUID, reportJSON []byte) error { + result := s.db.Model(&Report{}).Where("test_id = ?", testID).Update("report_json", reportJSON) + if result.Error != nil { + return fmt.Errorf("failed to update report: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + // DeleteOldReports deletes reports older than the specified time func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { result := s.db.Where("created_at < ?", olderThan).Delete(&Report{}) diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 3588280..dd082a5 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -23,6 +23,7 @@ package analyzer import ( "bytes" + "encoding/json" "fmt" "github.com/google/uuid" @@ -85,3 +86,32 @@ func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string { } return a.generator.GetScoreSummaryText(result.Results) } + +// APIAdapter adapts the EmailAnalyzer to work with the API package +// This adapter implements the interface expected by the API handler +type APIAdapter struct { + analyzer *EmailAnalyzer +} + +// NewAPIAdapter creates a new API adapter for the email analyzer +func NewAPIAdapter(cfg *config.Config) *APIAdapter { + return &APIAdapter{ + analyzer: NewEmailAnalyzer(cfg), + } +} + +// AnalyzeEmailBytes performs analysis and returns JSON bytes directly +func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byte, error) { + result, err := a.analyzer.AnalyzeEmailBytes(rawEmail, testID) + if err != nil { + return nil, err + } + + // Marshal report to JSON + reportJSON, err := json.Marshal(result.Report) + if err != nil { + return nil, fmt.Errorf("failed to marshal report: %w", err) + } + + return reportJSON, nil +} diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 7672fa8..db3e447 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -1,7 +1,7 @@ @@ -208,9 +227,22 @@ {/if} - +
+ Test Another Email