// 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/config" "git.happydns.org/happyDeliver/pkg/analyzer" ) // 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 { report := result.Report // Header with overall score fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT") fmt.Fprintln(writer, strings.Repeat("=", 70)) fmt.Fprintf(writer, "\nOverall Score: %d/100 (Grade: %s)\n", report.Score, report.Grade) fmt.Fprintf(writer, "Test ID: %s\n", report.TestId) fmt.Fprintf(writer, "Generated: %s\n", report.CreatedAt.Format("2006-01-02 15:04:05 MST")) // Score Summary if report.Summary != nil { fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) fmt.Fprintln(writer, "SCORE BREAKDOWN") fmt.Fprintln(writer, strings.Repeat("-", 70)) summary := report.Summary fmt.Fprintf(writer, " DNS Configuration: %3d%% (%s)\n", summary.DnsScore, summary.DnsGrade) fmt.Fprintf(writer, " Authentication: %3d%% (%s)\n", summary.AuthenticationScore, summary.AuthenticationGrade) fmt.Fprintf(writer, " Blacklist Status: %3d%% (%s)\n", summary.BlacklistScore, summary.BlacklistGrade) fmt.Fprintf(writer, " Header Quality: %3d%% (%s)\n", summary.HeaderScore, summary.HeaderGrade) fmt.Fprintf(writer, " Spam Score: %3d%% (%s)\n", summary.SpamScore, summary.SpamGrade) fmt.Fprintf(writer, " Content Quality: %3d%% (%s)\n", summary.ContentScore, summary.ContentGrade) } // DNS Results if report.DnsResults != nil { fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) fmt.Fprintln(writer, "DNS CONFIGURATION") fmt.Fprintln(writer, strings.Repeat("-", 70)) dns := report.DnsResults fmt.Fprintf(writer, "\nFrom Domain: %s\n", dns.FromDomain) if dns.RpDomain != nil && *dns.RpDomain != dns.FromDomain { fmt.Fprintf(writer, "Return-Path Domain: %s\n", *dns.RpDomain) } // MX Records if dns.FromMxRecords != nil && len(*dns.FromMxRecords) > 0 { fmt.Fprintln(writer, "\n MX Records (From Domain):") for _, mx := range *dns.FromMxRecords { status := "✓" if !mx.Valid { status = "✗" } fmt.Fprintf(writer, " %s [%d] %s", status, mx.Priority, mx.Host) if mx.Error != nil { fmt.Fprintf(writer, " - ERROR: %s", *mx.Error) } fmt.Fprintln(writer) } } // SPF Records if dns.SpfRecords != nil && len(*dns.SpfRecords) > 0 { fmt.Fprintln(writer, "\n SPF Records:") for _, spf := range *dns.SpfRecords { status := "✓" if !spf.Valid { status = "✗" } fmt.Fprintf(writer, " %s ", status) if spf.Domain != nil { fmt.Fprintf(writer, "Domain: %s", *spf.Domain) } if spf.AllQualifier != nil { fmt.Fprintf(writer, " (all: %s)", *spf.AllQualifier) } fmt.Fprintln(writer) if spf.Record != nil { fmt.Fprintf(writer, " %s\n", *spf.Record) } if spf.Error != nil { fmt.Fprintf(writer, " ERROR: %s\n", *spf.Error) } } } // DKIM Records if dns.DkimRecords != nil && len(*dns.DkimRecords) > 0 { fmt.Fprintln(writer, "\n DKIM Records:") for _, dkim := range *dns.DkimRecords { status := "✓" if !dkim.Valid { status = "✗" } fmt.Fprintf(writer, " %s Selector: %s, Domain: %s\n", status, dkim.Selector, dkim.Domain) if dkim.Record != nil { fmt.Fprintf(writer, " %s\n", *dkim.Record) } if dkim.Error != nil { fmt.Fprintf(writer, " ERROR: %s\n", *dkim.Error) } } } // DMARC Record if dns.DmarcRecord != nil { fmt.Fprintln(writer, "\n DMARC Record:") status := "✓" if !dns.DmarcRecord.Valid { status = "✗" } fmt.Fprintf(writer, " %s Valid: %t", status, dns.DmarcRecord.Valid) if dns.DmarcRecord.Policy != nil { fmt.Fprintf(writer, ", Policy: %s", *dns.DmarcRecord.Policy) } if dns.DmarcRecord.SubdomainPolicy != nil { fmt.Fprintf(writer, ", Subdomain Policy: %s", *dns.DmarcRecord.SubdomainPolicy) } fmt.Fprintln(writer) if dns.DmarcRecord.Record != nil { fmt.Fprintf(writer, " %s\n", *dns.DmarcRecord.Record) } if dns.DmarcRecord.Error != nil { fmt.Fprintf(writer, " ERROR: %s\n", *dns.DmarcRecord.Error) } } // BIMI Record if dns.BimiRecord != nil { fmt.Fprintln(writer, "\n BIMI Record:") status := "✓" if !dns.BimiRecord.Valid { status = "✗" } fmt.Fprintf(writer, " %s Valid: %t, Selector: %s, Domain: %s\n", status, dns.BimiRecord.Valid, dns.BimiRecord.Selector, dns.BimiRecord.Domain) if dns.BimiRecord.LogoUrl != nil { fmt.Fprintf(writer, " Logo URL: %s\n", *dns.BimiRecord.LogoUrl) } if dns.BimiRecord.VmcUrl != nil { fmt.Fprintf(writer, " VMC URL: %s\n", *dns.BimiRecord.VmcUrl) } if dns.BimiRecord.Record != nil { fmt.Fprintf(writer, " %s\n", *dns.BimiRecord.Record) } if dns.BimiRecord.Error != nil { fmt.Fprintf(writer, " ERROR: %s\n", *dns.BimiRecord.Error) } } // PTR Records if dns.PtrRecords != nil && len(*dns.PtrRecords) > 0 { fmt.Fprintln(writer, "\n PTR (Reverse DNS) Records:") for _, ptr := range *dns.PtrRecords { fmt.Fprintf(writer, " %s\n", ptr) } } // DNS Errors if dns.Errors != nil && len(*dns.Errors) > 0 { fmt.Fprintln(writer, "\n DNS Errors:") for _, err := range *dns.Errors { fmt.Fprintf(writer, " ! %s\n", err) } } } // Authentication Results if report.Authentication != nil { fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) fmt.Fprintln(writer, "AUTHENTICATION RESULTS") fmt.Fprintln(writer, strings.Repeat("-", 70)) auth := report.Authentication // SPF if auth.Spf != nil { fmt.Fprintf(writer, "\n SPF: %s", strings.ToUpper(string(auth.Spf.Result))) if auth.Spf.Domain != nil { fmt.Fprintf(writer, " (domain: %s)", *auth.Spf.Domain) } if auth.Spf.Details != nil { fmt.Fprintf(writer, "\n Details: %s", *auth.Spf.Details) } fmt.Fprintln(writer) } // DKIM if auth.Dkim != nil && len(*auth.Dkim) > 0 { fmt.Fprintln(writer, "\n DKIM:") for i, dkim := range *auth.Dkim { fmt.Fprintf(writer, " [%d] %s", i+1, strings.ToUpper(string(dkim.Result))) if dkim.Domain != nil { fmt.Fprintf(writer, " (domain: %s", *dkim.Domain) if dkim.Selector != nil { fmt.Fprintf(writer, ", selector: %s", *dkim.Selector) } fmt.Fprintf(writer, ")") } if dkim.Details != nil { fmt.Fprintf(writer, "\n Details: %s", *dkim.Details) } fmt.Fprintln(writer) } } // DMARC if auth.Dmarc != nil { fmt.Fprintf(writer, "\n DMARC: %s", strings.ToUpper(string(auth.Dmarc.Result))) if auth.Dmarc.Domain != nil { fmt.Fprintf(writer, " (domain: %s)", *auth.Dmarc.Domain) } if auth.Dmarc.Details != nil { fmt.Fprintf(writer, "\n Details: %s", *auth.Dmarc.Details) } fmt.Fprintln(writer) } // ARC if auth.Arc != nil { fmt.Fprintf(writer, "\n ARC: %s", strings.ToUpper(string(auth.Arc.Result))) if auth.Arc.ChainLength != nil { fmt.Fprintf(writer, " (chain length: %d)", *auth.Arc.ChainLength) } if auth.Arc.ChainValid != nil { fmt.Fprintf(writer, " [valid: %t]", *auth.Arc.ChainValid) } if auth.Arc.Details != nil { fmt.Fprintf(writer, "\n Details: %s", *auth.Arc.Details) } fmt.Fprintln(writer) } // BIMI if auth.Bimi != nil { fmt.Fprintf(writer, "\n BIMI: %s", strings.ToUpper(string(auth.Bimi.Result))) if auth.Bimi.Domain != nil { fmt.Fprintf(writer, " (domain: %s)", *auth.Bimi.Domain) } if auth.Bimi.Details != nil { fmt.Fprintf(writer, "\n Details: %s", *auth.Bimi.Details) } fmt.Fprintln(writer) } // IP Reverse if auth.Iprev != nil { fmt.Fprintf(writer, "\n IP Reverse DNS: %s", strings.ToUpper(string(auth.Iprev.Result))) if auth.Iprev.Ip != nil { fmt.Fprintf(writer, " (ip: %s", *auth.Iprev.Ip) if auth.Iprev.Hostname != nil { fmt.Fprintf(writer, " -> %s", *auth.Iprev.Hostname) } fmt.Fprintf(writer, ")") } if auth.Iprev.Details != nil { fmt.Fprintf(writer, "\n Details: %s", *auth.Iprev.Details) } fmt.Fprintln(writer) } } // Blacklist Results if report.Blacklists != nil && len(*report.Blacklists) > 0 { fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) fmt.Fprintln(writer, "BLACKLIST CHECKS") fmt.Fprintln(writer, strings.Repeat("-", 70)) totalChecks := 0 totalListed := 0 for ip, checks := range *report.Blacklists { totalChecks += len(checks) fmt.Fprintf(writer, "\n IP Address: %s\n", ip) for _, check := range checks { status := "✓" if check.Listed { status = "✗" totalListed++ } fmt.Fprintf(writer, " %s %s", status, check.Rbl) if check.Listed { fmt.Fprintf(writer, " - LISTED") if check.Response != nil { fmt.Fprintf(writer, " (%s)", *check.Response) } } else { fmt.Fprintf(writer, " - OK") } fmt.Fprintln(writer) if check.Error != nil { fmt.Fprintf(writer, " ERROR: %s\n", *check.Error) } } } fmt.Fprintf(writer, "\n Summary: %d/%d blacklists triggered\n", totalListed, totalChecks) } // Header Analysis if report.HeaderAnalysis != nil { fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) fmt.Fprintln(writer, "HEADER ANALYSIS") fmt.Fprintln(writer, strings.Repeat("-", 70)) header := report.HeaderAnalysis // Domain Alignment if header.DomainAlignment != nil { fmt.Fprintln(writer, "\n Domain Alignment:") align := header.DomainAlignment if align.FromDomain != nil { fmt.Fprintf(writer, " From Domain: %s", *align.FromDomain) if align.FromOrgDomain != nil { fmt.Fprintf(writer, " (org: %s)", *align.FromOrgDomain) } fmt.Fprintln(writer) } if align.ReturnPathDomain != nil { fmt.Fprintf(writer, " Return-Path Domain: %s", *align.ReturnPathDomain) if align.ReturnPathOrgDomain != nil { fmt.Fprintf(writer, " (org: %s)", *align.ReturnPathOrgDomain) } fmt.Fprintln(writer) } if align.Aligned != nil { fmt.Fprintf(writer, " Strict Alignment: %t\n", *align.Aligned) } if align.RelaxedAligned != nil { fmt.Fprintf(writer, " Relaxed Alignment: %t\n", *align.RelaxedAligned) } if align.Issues != nil && len(*align.Issues) > 0 { fmt.Fprintln(writer, " Issues:") for _, issue := range *align.Issues { fmt.Fprintf(writer, " - %s\n", issue) } } } // Required/Important Headers if header.Headers != nil { fmt.Fprintln(writer, "\n Standard Headers:") importantHeaders := []string{"from", "to", "subject", "date", "message-id", "dkim-signature"} for _, hdrName := range importantHeaders { if hdr, ok := (*header.Headers)[hdrName]; ok { status := "✗" if hdr.Present { status = "✓" } fmt.Fprintf(writer, " %s %s: ", status, strings.ToUpper(hdrName)) if hdr.Present { if hdr.Valid != nil && !*hdr.Valid { fmt.Fprintf(writer, "INVALID") } else { fmt.Fprintf(writer, "OK") } if hdr.Importance != nil { fmt.Fprintf(writer, " [%s]", *hdr.Importance) } } else { fmt.Fprintf(writer, "MISSING") } fmt.Fprintln(writer) if hdr.Issues != nil && len(*hdr.Issues) > 0 { for _, issue := range *hdr.Issues { fmt.Fprintf(writer, " - %s\n", issue) } } } } } // Header Issues if header.Issues != nil && len(*header.Issues) > 0 { fmt.Fprintln(writer, "\n Header Issues:") for _, issue := range *header.Issues { fmt.Fprintf(writer, " [%s] %s: %s\n", strings.ToUpper(string(issue.Severity)), issue.Header, issue.Message) if issue.Advice != nil { fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) } } } // Received Chain if header.ReceivedChain != nil && len(*header.ReceivedChain) > 0 { fmt.Fprintln(writer, "\n Email Path (Received Chain):") for i, hop := range *header.ReceivedChain { fmt.Fprintf(writer, " [%d] ", i+1) if hop.From != nil { fmt.Fprintf(writer, "%s", *hop.From) if hop.Ip != nil { fmt.Fprintf(writer, " (%s)", *hop.Ip) } } if hop.By != nil { fmt.Fprintf(writer, " -> %s", *hop.By) } fmt.Fprintln(writer) if hop.Timestamp != nil { fmt.Fprintf(writer, " Time: %s\n", hop.Timestamp.Format("2006-01-02 15:04:05 MST")) } } } } // SpamAssassin Results if report.Spamassassin != nil { fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) fmt.Fprintln(writer, "SPAMASSASSIN ANALYSIS") fmt.Fprintln(writer, strings.Repeat("-", 70)) sa := report.Spamassassin fmt.Fprintf(writer, "\n Score: %.2f / %.2f", sa.Score, sa.RequiredScore) if sa.IsSpam { fmt.Fprintf(writer, " (SPAM)") } else { fmt.Fprintf(writer, " (HAM)") } fmt.Fprintln(writer) if sa.Version != nil { fmt.Fprintf(writer, " Version: %s\n", *sa.Version) } if len(sa.TestDetails) > 0 { fmt.Fprintln(writer, "\n Triggered Tests:") for _, test := range sa.TestDetails { scoreStr := "+" if test.Score < 0 { scoreStr = "" } fmt.Fprintf(writer, " [%s%.2f] %s", scoreStr, test.Score, test.Name) if test.Description != nil { fmt.Fprintf(writer, "\n %s", *test.Description) } fmt.Fprintln(writer) } } } // Content Analysis if report.ContentAnalysis != nil { fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) fmt.Fprintln(writer, "CONTENT ANALYSIS") fmt.Fprintln(writer, strings.Repeat("-", 70)) content := report.ContentAnalysis // Basic content info fmt.Fprintln(writer, "\n Content Structure:") if content.HasPlaintext != nil { fmt.Fprintf(writer, " Has Plaintext: %t\n", *content.HasPlaintext) } if content.HasHtml != nil { fmt.Fprintf(writer, " Has HTML: %t\n", *content.HasHtml) } if content.TextToImageRatio != nil { fmt.Fprintf(writer, " Text-to-Image Ratio: %.2f\n", *content.TextToImageRatio) } // Unsubscribe if content.HasUnsubscribeLink != nil { fmt.Fprintf(writer, " Has Unsubscribe Link: %t\n", *content.HasUnsubscribeLink) if *content.HasUnsubscribeLink && content.UnsubscribeMethods != nil && len(*content.UnsubscribeMethods) > 0 { fmt.Fprintf(writer, " Unsubscribe Methods: ") for i, method := range *content.UnsubscribeMethods { if i > 0 { fmt.Fprintf(writer, ", ") } fmt.Fprintf(writer, "%s", method) } fmt.Fprintln(writer) } } // Links if content.Links != nil && len(*content.Links) > 0 { fmt.Fprintf(writer, "\n Links (%d total):\n", len(*content.Links)) for _, link := range *content.Links { status := "" switch link.Status { case "valid": status = "✓" case "broken": status = "✗" case "suspicious": status = "⚠" case "redirected": status = "→" case "timeout": status = "⏱" } fmt.Fprintf(writer, " %s [%s] %s", status, link.Status, link.Url) if link.HttpCode != nil { fmt.Fprintf(writer, " (HTTP %d)", *link.HttpCode) } fmt.Fprintln(writer) if link.RedirectChain != nil && len(*link.RedirectChain) > 0 { fmt.Fprintln(writer, " Redirect chain:") for _, url := range *link.RedirectChain { fmt.Fprintf(writer, " -> %s\n", url) } } } } // Images if content.Images != nil && len(*content.Images) > 0 { fmt.Fprintf(writer, "\n Images (%d total):\n", len(*content.Images)) missingAlt := 0 trackingPixels := 0 for _, img := range *content.Images { if !img.HasAlt { missingAlt++ } if img.IsTrackingPixel != nil && *img.IsTrackingPixel { trackingPixels++ } } fmt.Fprintf(writer, " Images with ALT text: %d/%d\n", len(*content.Images)-missingAlt, len(*content.Images)) if trackingPixels > 0 { fmt.Fprintf(writer, " Tracking pixels detected: %d\n", trackingPixels) } } // HTML Issues if content.HtmlIssues != nil && len(*content.HtmlIssues) > 0 { fmt.Fprintln(writer, "\n Content Issues:") for _, issue := range *content.HtmlIssues { fmt.Fprintf(writer, " [%s] %s: %s\n", strings.ToUpper(string(issue.Severity)), issue.Type, issue.Message) if issue.Location != nil { fmt.Fprintf(writer, " Location: %s\n", *issue.Location) } if issue.Advice != nil { fmt.Fprintf(writer, " Advice: %s\n", *issue.Advice) } } } } // Footer fmt.Fprintln(writer, "\n"+strings.Repeat("=", 70)) fmt.Fprintf(writer, "Report generated by happyDeliver - https://happydeliver.org\n") fmt.Fprintln(writer, strings.Repeat("=", 70)) return nil }