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:
$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:

View file

@ -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) {

View file

@ -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") == "" {

View file

@ -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{})

View file

@ -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
}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
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 { ScoreCard, CheckCard, SpamAssassinCard, PendingState } from "$lib/components";
@ -10,6 +10,7 @@
let report = $state<Report | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let reanalyzing = $state(false);
let pollInterval: ReturnType<typeof setInterval> | null = null;
let nextfetch = $state(23);
let nbfetch = $state(0);
@ -134,6 +135,24 @@
if (percentage >= 50) return "text-warning";
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>
<svelte:head>
@ -208,9 +227,22 @@
</div>
{/if}
<!-- Test Again Button -->
<!-- Action Buttons -->
<div class="row">
<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">
<i class="bi bi-arrow-repeat me-2"></i>
Test Another Email