diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index 4334711..d8336a5 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -86,18 +86,549 @@ func outputJSON(result *analyzer.AnalysisResult, writer io.Writer) error { // outputHumanReadable outputs a human-readable summary func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyzer.EmailAnalyzer, writer io.Writer) error { - // Header + 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")) - // Detailed checks - fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) - fmt.Fprintln(writer, "DETAILED CHECK RESULTS") - fmt.Fprintln(writer, strings.Repeat("-", 70)) + // Score Summary + if report.Summary != nil { + fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) + fmt.Fprintln(writer, "SCORE BREAKDOWN") + fmt.Fprintln(writer, strings.Repeat("-", 70)) - // TODO + 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 }