// 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 . // // 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 . 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) }