diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index da8ccb1..01d99f1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -24,17 +24,11 @@ package main import ( "flag" "fmt" - "io" "log" "os" - "github.com/gin-gonic/gin" - - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/app" "git.happydns.org/happyDeliver/internal/config" - "git.happydns.org/happyDeliver/internal/receiver" - "git.happydns.org/happyDeliver/internal/storage" - "git.happydns.org/happyDeliver/web" ) const version = "0.1.0-dev" @@ -52,9 +46,13 @@ func main() { switch command { case "server": - runServer(cfg) + if err := app.RunServer(cfg); err != nil { + log.Fatalf("Server error: %v", err) + } case "analyze": - runAnalyzer(cfg) + if err := app.RunAnalyzer(cfg, flag.Args()[1:], os.Stdin, os.Stdout); err != nil { + log.Fatalf("Analyzer error: %v", err) + } case "version": fmt.Println(version) default: @@ -64,94 +62,11 @@ func main() { } } -func runServer(cfg *config.Config) { - if err := cfg.Validate(); err != nil { - log.Fatalf("Invalid configuration: %v", err) - } - - // Initialize storage - store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) - if err != nil { - log.Fatalf("Failed to initialize storage: %v", err) - } - defer store.Close() - - log.Printf("Connected to %s database", cfg.Database.Type) - - // Create API handler - handler := api.NewAPIHandler(store, cfg) - - // Set up Gin router - if os.Getenv("GIN_MODE") == "" { - gin.SetMode(gin.ReleaseMode) - } - router := gin.Default() - - // Register API routes - apiGroup := router.Group("/api") - api.RegisterHandlers(apiGroup, handler) - web.DeclareRoutes(cfg, router) - - // Start server - log.Printf("Starting API server on %s", cfg.Bind) - log.Printf("Test email domain: %s", cfg.Email.Domain) - - if err := router.Run(cfg.Bind); err != nil { - log.Fatalf("Failed to start server: %v", err) - } -} - -func runAnalyzer(cfg *config.Config) { - // Parse command-line flags - fs := flag.NewFlagSet("analyze", flag.ExitOnError) - recipientEmail := fs.String("recipient", "", "Recipient email address (optional, will be extracted from headers if not provided)") - fs.Parse(flag.Args()[1:]) - - if err := cfg.Validate(); err != nil { - log.Fatalf("Invalid configuration: %v", err) - } - - // Initialize storage - store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) - if err != nil { - log.Fatalf("Failed to initialize storage: %v", err) - } - defer store.Close() - - log.Printf("Email analyzer ready, reading from stdin...") - - // Read email from stdin - emailData, err := io.ReadAll(os.Stdin) - if err != nil { - log.Fatalf("Failed to read email from stdin: %v", err) - } - - // If recipient not provided, try to extract from headers - var recipient string - if *recipientEmail != "" { - recipient = *recipientEmail - } else { - recipient, err = receiver.ExtractRecipientFromHeaders(emailData) - if err != nil { - log.Fatalf("Failed to extract recipient: %v", err) - } - log.Printf("Extracted recipient: %s", recipient) - } - - // Process the email - recv := receiver.NewEmailReceiver(store, cfg) - if err := recv.ProcessEmailBytes(emailData, recipient); err != nil { - log.Fatalf("Failed to process email: %v", err) - } - - log.Println("Email processed successfully") -} - func printUsage() { fmt.Println("\nCommand availables:") - fmt.Println(" happyDeliver server - Start the API server") - fmt.Println(" happyDeliver analyze [-recipient EMAIL] - Analyze email from stdin (MDA mode)") - fmt.Println(" happyDeliver version - Print version information") + fmt.Println(" happyDeliver server - Start the API server") + fmt.Println(" happyDeliver analyze [-json] - Analyze email from stdin and output results to terminal") + fmt.Println(" happyDeliver version - Print version information") fmt.Println("") flag.Usage() } diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go new file mode 100644 index 0000000..3588280 --- /dev/null +++ b/internal/analyzer/analyzer.go @@ -0,0 +1,87 @@ +// 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 analyzer + +import ( + "bytes" + "fmt" + + "github.com/google/uuid" + + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/config" +) + +// EmailAnalyzer provides high-level email analysis functionality +// This is the main entry point for analyzing emails from both LMTP and CLI +type EmailAnalyzer struct { + generator *ReportGenerator +} + +// NewEmailAnalyzer creates a new email analyzer with the given configuration +func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { + generator := NewReportGenerator( + cfg.Analysis.DNSTimeout, + cfg.Analysis.HTTPTimeout, + cfg.Analysis.RBLs, + ) + + return &EmailAnalyzer{ + generator: generator, + } +} + +// AnalysisResult contains the complete analysis result +type AnalysisResult struct { + Email *EmailMessage + Results *AnalysisResults + Report *api.Report +} + +// AnalyzeEmailBytes performs complete email analysis from raw bytes +func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*AnalysisResult, error) { + // Parse the email + emailMsg, err := ParseEmail(bytes.NewReader(rawEmail)) + if err != nil { + return nil, fmt.Errorf("failed to parse email: %w", err) + } + + // Analyze the email + results := a.generator.AnalyzeEmail(emailMsg) + + // Generate the report + report := a.generator.GenerateReport(testID, results) + + return &AnalysisResult{ + Email: emailMsg, + Results: results, + Report: report, + }, nil +} + +// GetScoreSummaryText returns a human-readable score summary +func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string { + if result == nil || result.Results == nil { + return "" + } + return a.generator.GetScoreSummaryText(result.Results) +} diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go new file mode 100644 index 0000000..87a4e0a --- /dev/null +++ b/internal/app/cli_analyzer.go @@ -0,0 +1,143 @@ +// 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 app + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "log" + "strings" + + "github.com/google/uuid" + + "git.happydns.org/happyDeliver/internal/analyzer" + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/config" +) + +// RunAnalyzer runs the standalone email analyzer (from stdin) +func RunAnalyzer(cfg *config.Config, args []string, reader io.Reader, writer io.Writer) error { + // Parse command-line flags + fs := flag.NewFlagSet("analyze", flag.ExitOnError) + jsonOutput := fs.Bool("json", false, "Output results as JSON") + if err := fs.Parse(args); err != nil { + return err + } + + if err := cfg.Validate(); err != nil { + return err + } + + log.Printf("Email analyzer ready, reading from stdin...") + + // Read email from stdin + emailData, err := io.ReadAll(reader) + if err != nil { + return fmt.Errorf("failed to read email from stdin: %w", err) + } + + // Create analyzer with configuration + emailAnalyzer := analyzer.NewEmailAnalyzer(cfg) + + // Analyze the email (using a dummy test ID for standalone mode) + result, err := emailAnalyzer.AnalyzeEmailBytes(emailData, uuid.New()) + if err != nil { + return fmt.Errorf("failed to analyze email: %w", err) + } + + log.Printf("Analyzing email from: %s", result.Email.From) + + // Output results + if *jsonOutput { + return outputJSON(result, writer) + } + return outputHumanReadable(result, emailAnalyzer, writer) +} + +// outputJSON outputs the report as JSON +func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error { + reportJSON, err := json.MarshalIndent(result.Report, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal report: %w", err) + } + fmt.Fprintln(writer, string(reportJSON)) + return nil +} + +// outputHumanReadable outputs a human-readable summary +func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error { + // Header + fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) + fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT") + fmt.Fprintln(writer, strings.Repeat("=", 70)) + + // Score summary + summary := emailAnalyzer.GetScoreSummaryText(result) + fmt.Fprintln(writer, summary) + + // Detailed checks + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "DETAILED CHECK RESULTS") + fmt.Fprintln(writer, strings.Repeat("-", 70)) + + // Group checks by category + categories := make(map[api.CheckCategory][]api.Check) + for _, check := range result.Report.Checks { + categories[check.Category] = append(categories[check.Category], check) + } + + // Print checks by category + categoryOrder := []api.CheckCategory{ + api.Authentication, + api.Dns, + api.Blacklist, + api.Content, + api.Headers, + } + + for _, category := range categoryOrder { + checks, ok := categories[category] + if !ok || len(checks) == 0 { + continue + } + + fmt.Fprintf(writer, "\n%s:\n", category) + for _, check := range checks { + statusSymbol := "✓" + if check.Status == api.CheckStatusFail { + statusSymbol = "✗" + } else if check.Status == api.CheckStatusWarn { + statusSymbol = "⚠" + } + + fmt.Fprintf(writer, " %s %s: %s\n", statusSymbol, check.Name, check.Message) + if check.Advice != nil && *check.Advice != "" { + fmt.Fprintf(writer, " → %s\n", *check.Advice) + } + } + } + + fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) + return nil +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..9c7d28b --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,74 @@ +// 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 app + +import ( + "log" + "os" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/web" +) + +// RunServer starts the API server server +func RunServer(cfg *config.Config) error { + if err := cfg.Validate(); err != nil { + return err + } + + // Initialize storage + store, err := storage.NewStorage(cfg.Database.Type, cfg.Database.DSN) + if err != nil { + return err + } + defer store.Close() + + log.Printf("Connected to %s database", cfg.Database.Type) + + // Create API handler + handler := api.NewAPIHandler(store, cfg) + + // Set up Gin router + if os.Getenv("GIN_MODE") == "" { + gin.SetMode(gin.ReleaseMode) + } + router := gin.Default() + + // Register API routes + apiGroup := router.Group("/api") + api.RegisterHandlers(apiGroup, handler) + web.DeclareRoutes(cfg, router) + + // Start API server + log.Printf("Starting API server on %s", cfg.Bind) + log.Printf("Test email domain: %s", cfg.Email.Domain) + + if err := router.Run(cfg.Bind); err != nil { + return err + } + + return nil +} diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index 55a03ec..325ef31 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -22,7 +22,6 @@ package receiver import ( - "bytes" "encoding/json" "fmt" "io" @@ -39,15 +38,17 @@ import ( // EmailReceiver handles incoming emails from the MTA type EmailReceiver struct { - storage storage.Storage - config *config.Config + storage storage.Storage + config *config.Config + analyzer *analyzer.EmailAnalyzer } // NewEmailReceiver creates a new email receiver func NewEmailReceiver(store storage.Storage, cfg *config.Config) *EmailReceiver { return &EmailReceiver{ - storage: store, - config: cfg, + storage: store, + config: cfg, + analyzer: analyzer.NewEmailAnalyzer(cfg), } } @@ -92,33 +93,20 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string log.Printf("Analyzing email for test %s", testID) - // Parse the email - emailMsg, err := analyzer.ParseEmail(bytes.NewReader(rawEmail)) + // Analyze the email using the shared analyzer + result, err := r.analyzer.AnalyzeEmailBytes(rawEmail, testID) if err != nil { // Update test status to failed if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil { log.Printf("Failed to update test status to failed: %v", updateErr) } - return fmt.Errorf("failed to parse email: %w", err) + return fmt.Errorf("failed to analyze email: %w", err) } - // Create report generator with configuration - generator := analyzer.NewReportGenerator( - r.config.Analysis.DNSTimeout, - r.config.Analysis.HTTPTimeout, - r.config.Analysis.RBLs, - ) - - // Analyze the email - results := generator.AnalyzeEmail(emailMsg) - - // Generate the report - report := generator.GenerateReport(testID, results) - - log.Printf("Analysis complete. Score: %.2f/10", report.Score) + log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score) // Marshal report to JSON - reportJSON, err := json.Marshal(report) + reportJSON, err := json.Marshal(result.Report) if err != nil { // Update test status to failed if updateErr := r.storage.UpdateTestStatus(testID, storage.StatusFailed); updateErr != nil {