happyDeliver/internal/app/cli_analyzer.go

634 lines
19 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 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
}