New route to perform a new analysis of the email
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
nemunaire 2025-10-20 19:33:23 +07:00
commit dfc0eeb323
6 changed files with 183 additions and 4 deletions

View file

@ -134,6 +134,41 @@ paths:
schema: schema:
$ref: '#/components/schemas/Error' $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: /status:
get: get:
tags: tags:

View file

@ -35,18 +35,26 @@ import (
"git.happydns.org/happyDeliver/internal/utils" "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 // APIHandler implements the ServerInterface for handling API requests
type APIHandler struct { type APIHandler struct {
storage storage.Storage storage storage.Storage
config *config.Config config *config.Config
analyzer EmailAnalyzer
startTime time.Time startTime time.Time
} }
// NewAPIHandler creates a new API handler // 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{ return &APIHandler{
storage: store, storage: store,
config: cfg, config: cfg,
analyzer: analyzer,
startTime: time.Now(), startTime: time.Now(),
} }
} }
@ -192,6 +200,63 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
c.Data(http.StatusOK, "text/plain", rawEmail) 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 // GetStatus retrieves service health status
// (GET /status) // (GET /status)
func (h *APIHandler) GetStatus(c *gin.Context) { func (h *APIHandler) GetStatus(c *gin.Context) {

View file

@ -32,6 +32,7 @@ import (
"git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/lmtp" "git.happydns.org/happyDeliver/internal/lmtp"
"git.happydns.org/happyDeliver/internal/storage" "git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/pkg/analyzer"
"git.happydns.org/happyDeliver/web" "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 // Create API handler
handler := api.NewAPIHandler(store, cfg) handler := api.NewAPIHandler(store, cfg, analyzerAdapter)
// Set up Gin router // Set up Gin router
if os.Getenv("GIN_MODE") == "" { if os.Getenv("GIN_MODE") == "" {

View file

@ -43,6 +43,7 @@ type Storage interface {
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) ReportExists(testID uuid.UUID) (bool, error)
UpdateReport(testID uuid.UUID, reportJSON []byte) error
DeleteOldReports(olderThan time.Time) (int64, error) DeleteOldReports(olderThan time.Time) (int64, error)
// Close closes the database connection // 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 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 // DeleteOldReports deletes reports older than the specified time
func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
result := s.db.Where("created_at < ?", olderThan).Delete(&Report{}) result := s.db.Where("created_at < ?", olderThan).Delete(&Report{})

View file

@ -23,6 +23,7 @@ package analyzer
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"github.com/google/uuid" "github.com/google/uuid"
@ -85,3 +86,32 @@ func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string {
} }
return a.generator.GetScoreSummaryText(result.Results) 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
}

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { page } from "$app/state"; import { page } from "$app/state";
import { getTest, getReport } from "$lib/api"; import { getTest, getReport, reanalyzeReport } from "$lib/api";
import type { Test, Report } from "$lib/api/types.gen"; import type { Test, Report } from "$lib/api/types.gen";
import { ScoreCard, CheckCard, SpamAssassinCard, PendingState } from "$lib/components"; import { ScoreCard, CheckCard, SpamAssassinCard, PendingState } from "$lib/components";
@ -10,6 +10,7 @@
let report = $state<Report | null>(null); let report = $state<Report | null>(null);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let reanalyzing = $state(false);
let pollInterval: ReturnType<typeof setInterval> | null = null; let pollInterval: ReturnType<typeof setInterval> | null = null;
let nextfetch = $state(23); let nextfetch = $state(23);
let nbfetch = $state(0); let nbfetch = $state(0);
@ -134,6 +135,24 @@
if (percentage >= 50) return "text-warning"; if (percentage >= 50) return "text-warning";
return "text-danger"; return "text-danger";
} }
async function handleReanalyze() {
if (!testId || reanalyzing) return;
reanalyzing = true;
error = null;
try {
const response = await reanalyzeReport({ path: { id: testId } });
if (response.data) {
report = response.data;
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to reanalyze report";
} finally {
reanalyzing = false;
}
}
</script> </script>
<svelte:head> <svelte:head>
@ -208,9 +227,22 @@
</div> </div>
{/if} {/if}
<!-- Test Again Button --> <!-- Action Buttons -->
<div class="row"> <div class="row">
<div class="col-12 text-center"> <div class="col-12 text-center">
<button
class="btn btn-outline-secondary btn-lg me-3"
onclick={handleReanalyze}
disabled={reanalyzing}
>
{#if reanalyzing}
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Reanalyzing...
{:else}
<i class="bi bi-arrow-clockwise me-2"></i>
Reanalyze with Latest Version
{/if}
</button>
<a href="/test/" class="btn btn-primary btn-lg"> <a href="/test/" class="btn btn-primary btn-lg">
<i class="bi bi-arrow-repeat me-2"></i> <i class="bi bi-arrow-repeat me-2"></i>
Test Another Email Test Another Email