New route to perform a new analysis of the email
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
0ac51ac06d
commit
dfc0eeb323
6 changed files with 183 additions and 4 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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") == "" {
|
||||||
|
|
|
||||||
|
|
@ -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{})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue