happyDeliver/internal/api/handlers.go

382 lines
10 KiB
Go

// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
openapi_types "github.com/oapi-codegen/runtime/types"
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/version"
)
// 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)
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
CheckBlacklistIP(ip string) (checks []BlacklistCheck, listedCount int, score int, grade string, 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, analyzer EmailAnalyzer) *APIHandler {
return &APIHandler{
storage: store,
config: cfg,
analyzer: analyzer,
startTime: time.Now(),
}
}
// CreateTest creates a new deliverability test
// (POST /test)
func (h *APIHandler) CreateTest(c *gin.Context) {
// Generate a unique test ID (no database record created)
testID := uuid.New()
// Convert UUID to base32 string for the API response
base32ID := utils.UUIDToBase32(testID)
// Generate test email address using Base32-encoded UUID
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
base32ID,
h.config.Email.Domain,
)
// Return response
c.JSON(http.StatusCreated, TestResponse{
Id: base32ID,
Email: openapi_types.Email(email),
Status: TestResponseStatusPending,
Message: stringPtr("Send your test email to the given address"),
})
}
// GetTest retrieves test metadata
// (GET /test/{id})
func (h *APIHandler) GetTest(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
}
// Check if a report exists for this test ID
reportExists, err := h.storage.ReportExists(testUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to check test status",
Details: stringPtr(err.Error()),
})
return
}
// Determine status based on report existence
var apiStatus TestStatus
if reportExists {
apiStatus = TestStatusAnalyzed
} else {
apiStatus = TestStatusPending
}
// Generate test email address using Base32-encoded UUID
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
id,
h.config.Email.Domain,
)
c.JSON(http.StatusOK, Test{
Id: id,
Email: openapi_types.Email(email),
Status: apiStatus,
})
}
// GetReport retrieves the detailed analysis report
// (GET /report/{id})
func (h *APIHandler) GetReport(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
}
reportJSON, _, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, Error{
Error: "not_found",
Message: "Report not found",
})
return
}
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
Message: "Failed to retrieve report",
Details: stringPtr(err.Error()),
})
return
}
// Return raw JSON directly
c.Data(http.StatusOK, "application/json", reportJSON)
}
// GetRawEmail retrieves the raw annotated email
// (GET /report/{id}/raw)
func (h *APIHandler) GetRawEmail(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
}
_, 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 raw email",
Details: stringPtr(err.Error()),
})
return
}
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) {
// Calculate uptime
uptime := int(time.Since(h.startTime).Seconds())
// Check database connectivity by trying to check if a report exists
dbStatus := StatusComponentsDatabaseUp
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
dbStatus = StatusComponentsDatabaseDown
}
// Determine overall status
overallStatus := Healthy
if dbStatus == StatusComponentsDatabaseDown {
overallStatus = Unhealthy
}
mtaStatus := StatusComponentsMtaUp
c.JSON(http.StatusOK, Status{
Status: overallStatus,
Version: version.Version,
Components: &struct {
Database *StatusComponentsDatabase `json:"database,omitempty"`
Mta *StatusComponentsMta `json:"mta,omitempty"`
}{
Database: &dbStatus,
Mta: &mtaStatus,
},
Uptime: &uptime,
})
}
// TestDomain performs synchronous domain analysis
// (POST /domain)
func (h *APIHandler) TestDomain(c *gin.Context) {
var request DomainTestRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: stringPtr(err.Error()),
})
return
}
// Perform domain analysis
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
// Convert grade string to DomainTestResponseGrade enum
var responseGrade DomainTestResponseGrade
switch grade {
case "A+":
responseGrade = DomainTestResponseGradeA
case "A":
responseGrade = DomainTestResponseGradeA1
case "B":
responseGrade = DomainTestResponseGradeB
case "C":
responseGrade = DomainTestResponseGradeC
case "D":
responseGrade = DomainTestResponseGradeD
case "E":
responseGrade = DomainTestResponseGradeE
case "F":
responseGrade = DomainTestResponseGradeF
default:
responseGrade = DomainTestResponseGradeF
}
// Build response
response := DomainTestResponse{
Domain: request.Domain,
Score: score,
Grade: responseGrade,
DnsResults: *dnsResults,
}
c.JSON(http.StatusOK, response)
}
// CheckBlacklist checks an IP address against DNS blacklists
// (POST /blacklist)
func (h *APIHandler) CheckBlacklist(c *gin.Context) {
var request BlacklistCheckRequest
// Bind and validate request
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_request",
Message: "Invalid request body",
Details: stringPtr(err.Error()),
})
return
}
// Perform blacklist check using analyzer
checks, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
if err != nil {
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_ip",
Message: "Invalid IP address",
Details: stringPtr(err.Error()),
})
return
}
// Build response
response := BlacklistCheckResponse{
Ip: request.Ip,
Checks: checks,
ListedCount: listedCount,
Score: score,
Grade: BlacklistCheckResponseGrade(grade),
}
c.JSON(http.StatusOK, response)
}