package checker import ( "encoding/json" "fmt" "html/template" "sort" "strings" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) type reportFix struct { Severity string Code string Message string Fix string Endpoint string Target string } type reportMX struct { Preference uint16 Target string IPv4 []string IPv6 []string IsCNAME bool CNAMEChain []string IsIPLiteral bool ResolveErr string } type reportEndpoint struct { Target string Address string IP string IsIPv6 bool StatusLabel string StatusClass string AnyFail bool TCPConnected bool BannerLine string BannerHostname string BannerCode int EHLOReceived bool EHLOFallbackHELO bool EHLOHostname string STARTTLSOffered bool STARTTLSUpgraded bool TLSVersion string TLSCipher string SizeLimit uint64 HasPipelining bool Has8BITMIME bool HasSMTPUTF8 bool HasCHUNKING bool HasDSN bool HasENHANCEDCODE bool AUTHPreTLS []string AUTHPostTLS []string PTR string PTRError string FCrDNSPass bool NullSenderState string NullSenderClass string NullSenderResponse string PostmasterState string PostmasterClass string PostmasterResponse string OpenRelayState string OpenRelayClass string OpenRelayResponse string OpenRelayRecipient string ElapsedMS int64 Error string // TLS posture (from a related tls_probes observation, when available). TLSPosture *reportTLSPosture } type reportTLSPosture struct { CheckedAt time.Time ChainValid *bool HostnameMatch *bool NotAfter time.Time Issues []reportFix } type reportData struct { Domain string RunAt string StatusLabel string StatusClass string HasIssues bool Fixes []reportFix MX []reportMX NullMX bool ImplicitMX bool MXError string Endpoints []reportEndpoint HasIPv4 bool HasIPv6 bool AnySTARTTLS bool AllSTARTTLS bool HasTLSPosture bool } var reportTpl = template.Must(template.New("smtp").Funcs(template.FuncMap{ "deref": func(b *bool) bool { return b != nil && *b }, "humanBytes": func(n uint64) string { if n == 0 { return "no limit" } units := []string{"B", "KiB", "MiB", "GiB", "TiB"} f := float64(n) u := 0 for f >= 1024 && u < len(units)-1 { f /= 1024 u++ } return fmt.Sprintf("%.1f %s", f, units[u]) }, }).Parse(` SMTP Report: {{.Domain}}

SMTP: {{.Domain}}

{{.StatusLabel}}
{{if .NullMX}}null MX (refuses mail){{else}} {{if .AllSTARTTLS}}all STARTTLS {{else if .AnySTARTTLS}}partial STARTTLS {{else}}no STARTTLS{{end}} {{if .HasIPv4}}IPv4{{end}} {{if .HasIPv6}}IPv6{{end}} {{end}}
Checked {{.RunAt}}
{{if .HasIssues}}

What to fix

{{range .Fixes}}
{{.Code}}{{if .Target}} · {{.Target}}{{end}}{{if .Endpoint}}{{if not .Target}} · {{.Endpoint}}{{else}} ({{.Endpoint}}){{end}}{{end}}
{{.Message}}
{{if .Fix}}
→ {{.Fix}}
{{end}}
{{end}}
{{end}}

DNS / MX

{{if .NullMX}}

This domain publishes a null MX record: it explicitly does not accept email (RFC 7505).

{{else if .ImplicitMX}}

No MX record is published; senders will fall back to the domain's A/AAAA (implicit MX, discouraged).

{{else if .MXError}}

MX lookup failed: {{.MXError}}

{{else if .MX}} {{range .MX}} {{end}}
PrefTargetIPv4IPv6Issues
{{.Preference}} {{.Target}} {{range .IPv4}}{{.}} {{end}} {{range .IPv6}}{{.}} {{end}} {{if .IsIPLiteral}}IP literal{{end}} {{if .IsCNAME}}CNAME chain: {{range .CNAMEChain}}{{.}} {{end}}{{end}} {{if .ResolveErr}}resolve: {{.ResolveErr}}{{end}}
{{else}}

No MX records found.

{{end}}
{{if .Endpoints}}

Endpoints ({{len .Endpoints}})

{{range .Endpoints}} {{.Target}} · {{.Address}} {{.StatusLabel}}
Family
{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}
TCP :25
{{if .TCPConnected}}✓ connected{{else}}✗ failed{{end}}
{{if .BannerLine}}
Banner
{{if .BannerHostname}}
announced name: {{.BannerHostname}}
{{end}}
{{end}}
EHLO
{{if .EHLOFallbackHELO}}✗ EHLO rejected, only HELO works {{else if .EHLOReceived}}✓ accepted{{if .EHLOHostname}} ({{.EHLOHostname}}){{end}} {{else}}✗ failed{{end}}
{{if .EHLOReceived}}
Extensions
{{if .STARTTLSOffered}}STARTTLS{{else}}no STARTTLS{{end}} {{if .HasPipelining}}PIPELINING{{else}}no PIPELINING{{end}} {{if .Has8BITMIME}}8BITMIME{{end}} {{if .HasSMTPUTF8}}SMTPUTF8{{end}} {{if .HasCHUNKING}}CHUNKING{{end}} {{if .HasDSN}}DSN{{end}} {{if .HasENHANCEDCODE}}ENHANCEDSTATUSCODES{{end}} {{if .SizeLimit}}SIZE {{humanBytes .SizeLimit}}{{end}}
{{end}} {{if .AUTHPreTLS}}
AUTH pre-TLS
✗ advertised without TLS: {{range .AUTHPreTLS}}{{.}} {{end}}
{{end}} {{if .AUTHPostTLS}}
AUTH post-TLS
{{range .AUTHPostTLS}}{{.}} {{end}}
{{end}}
STARTTLS
{{if .STARTTLSUpgraded}}✓ {{.TLSVersion}}{{if .TLSCipher}} ({{.TLSCipher}}){{end}} {{else if .STARTTLSOffered}}✗ handshake failed {{else}}✗ not offered{{end}}
{{with .TLSPosture}}
TLS cert
{{if .ChainValid}}{{if deref .ChainValid}}✓ chain valid{{else}}✗ chain invalid{{end}}{{end}} {{if .HostnameMatch}} · {{if deref .HostnameMatch}}✓ hostname match{{else}}✗ hostname mismatch{{end}}{{end}} {{if not .NotAfter.IsZero}} · expires {{.NotAfter.Format "2006-01-02"}}{{end}} {{if not .CheckedAt.IsZero}}
TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}
{{end}} {{range .Issues}}
{{.Code}}
{{.Message}}
{{if .Fix}}
→ {{.Fix}}
{{end}}
{{end}}
{{end}}
PTR
{{if .PTR}}{{.PTR}} {{if .FCrDNSPass}}· ✓ FCrDNS {{else}}· ✗ FCrDNS mismatch{{end}} {{else}}✗ no PTR{{if .PTRError}} ({{.PTRError}}){{end}}{{end}}
{{if .NullSenderState}}
Null sender
{{.NullSenderState}}
{{.NullSenderResponse}}
{{end}} {{if .PostmasterState}}
Postmaster
{{.PostmasterState}}
{{.PostmasterResponse}}
{{end}} {{if .OpenRelayState}}
Open relay
{{.OpenRelayState}}
rcpt={{.OpenRelayRecipient}}: {{.OpenRelayResponse}}
{{end}}
Duration
{{.ElapsedMS}} ms
{{if .Error}}
Error
{{.Error}}
{{end}}
{{end}}
{{end}} `)) // GetHTMLReport implements sdk.CheckerHTMLReporter. func (p *smtpProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) { var d SMTPData if err := json.Unmarshal(rctx.Data(), &d); err != nil { return "", fmt.Errorf("unmarshal smtp observation: %w", err) } view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States()) return renderReport(view) } func renderReport(view reportData) (string, error) { var buf strings.Builder if err := reportTpl.Execute(&buf, view); err != nil { return "", fmt.Errorf("render smtp report: %w", err) } return buf.String(), nil } func buildReportData(d *SMTPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData { tlsByAddr := indexTLSByAddress(related) fixes := fixesFromStates(states) view := reportData{ Domain: d.Domain, RunAt: d.RunAt, NullMX: d.MX.NullMX, ImplicitMX: d.MX.ImplicitMX, MXError: d.MX.Error, HasIPv4: d.Coverage.HasIPv4, HasIPv6: d.Coverage.HasIPv6, AnySTARTTLS: d.Coverage.AnySTARTTLS, AllSTARTTLS: d.Coverage.AllSTARTTLS, HasIssues: len(fixes) > 0, HasTLSPosture: len(tlsByAddr) > 0, } view.StatusLabel, view.StatusClass = overallStatus(d, states, fixes) sevRank := func(s string) int { switch s { case SeverityCrit: return 0 case SeverityWarn: return 1 default: return 2 } } sort.SliceStable(fixes, func(i, j int) bool { return sevRank(fixes[i].Severity) < sevRank(fixes[j].Severity) }) view.Fixes = fixes for _, rec := range d.MX.Records { view.MX = append(view.MX, reportMX{ Preference: rec.Preference, Target: rec.Target, IPv4: rec.IPv4, IPv6: rec.IPv6, IsCNAME: rec.IsCNAME, CNAMEChain: rec.CNAMEChain, IsIPLiteral: rec.IsIPLiteral, ResolveErr: rec.ResolveError, }) } for _, ep := range d.Endpoints { re := reportEndpoint{ Target: ep.Target, Address: ep.Address, IP: ep.IP, IsIPv6: ep.IsIPv6, TCPConnected: ep.TCPConnected, BannerLine: ep.BannerLine, BannerHostname: ep.BannerHostname, BannerCode: ep.BannerCode, EHLOReceived: ep.EHLOReceived, EHLOFallbackHELO: ep.EHLOFallbackHELO, EHLOHostname: ep.EHLOHostname, STARTTLSOffered: ep.STARTTLSOffered, STARTTLSUpgraded: ep.STARTTLSUpgraded, TLSVersion: ep.TLSVersion, TLSCipher: ep.TLSCipher, SizeLimit: ep.SizeLimit, HasPipelining: ep.HasPipelining, Has8BITMIME: ep.Has8BITMIME, HasSMTPUTF8: ep.HasSMTPUTF8, HasCHUNKING: ep.HasCHUNKING, HasDSN: ep.HasDSN, HasENHANCEDCODE: ep.HasENHANCEDCODE, AUTHPreTLS: ep.AUTHPreTLS, AUTHPostTLS: ep.AUTHPostTLS, PTR: ep.PTR, PTRError: ep.PTRError, FCrDNSPass: ep.FCrDNSPass, NullSenderResponse: ep.NullSenderResponse, PostmasterResponse: ep.PostmasterResponse, OpenRelayResponse: ep.OpenRelayResponse, OpenRelayRecipient: ep.OpenRelayRecipient, ElapsedMS: ep.ElapsedMS, Error: ep.Error, } if ep.NullSenderAccepted != nil { if *ep.NullSenderAccepted { re.NullSenderState = "accepted" re.NullSenderClass = "ok" } else { re.NullSenderState = "REJECTED" re.NullSenderClass = "fail" } } if ep.PostmasterAccepted != nil { if *ep.PostmasterAccepted { re.PostmasterState = "accepted" re.PostmasterClass = "ok" } else { re.PostmasterState = "REJECTED" re.PostmasterClass = "fail" } } if ep.OpenRelay != nil { if *ep.OpenRelay { re.OpenRelayState = "OPEN RELAY" re.OpenRelayClass = "fail" } else { re.OpenRelayState = "properly refused" re.OpenRelayClass = "ok" } } if meta, hit := tlsByAddr[ep.Address]; hit { re.TLSPosture = meta } else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit { re.TLSPosture = meta } ok := ep.TCPConnected && ep.EHLOReceived if ep.STARTTLSOffered { ok = ok && ep.STARTTLSUpgraded } if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted { ok = false } if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted { ok = false } if ep.OpenRelay != nil && *ep.OpenRelay { ok = false } re.AnyFail = !ok switch { case !ep.TCPConnected: re.StatusLabel = "unreachable" re.StatusClass = "fail" case ep.OpenRelay != nil && *ep.OpenRelay: re.StatusLabel = "OPEN RELAY" re.StatusClass = "fail" case !ok: re.StatusLabel = "partial" re.StatusClass = "warn" default: re.StatusLabel = "OK" re.StatusClass = "ok" } view.Endpoints = append(view.Endpoints, re) } return view } // fixesFromStates turns the rule-driven CheckStates into the hint/fix // entries the report renders. It consumes Message, Meta["fix"], and Status // exclusively, the derivation of those fields lives in the rules, not // here. States that do not represent a finding (OK, Unknown) are skipped. func fixesFromStates(states []sdk.CheckState) []reportFix { out := make([]reportFix, 0, len(states)) for _, st := range states { sev := statusToSeverity(st.Status) if sev == "" { continue } fix := "" endpoint := "" target := "" if st.Meta != nil { if s, ok := st.Meta["fix"].(string); ok { fix = s } if s, ok := st.Meta["endpoint"].(string); ok { endpoint = s } if s, ok := st.Meta["target"].(string); ok { target = s } } out = append(out, reportFix{ Severity: sev, Code: st.Code, Message: st.Message, Fix: fix, Endpoint: endpoint, Target: target, }) } return out } // statusToSeverity maps an sdk.Status to the severity strings used by the // HTML template. Status values that represent a non-finding (OK, Unknown) // return "" so the caller can skip them. func statusToSeverity(s sdk.Status) string { switch s { case sdk.StatusCrit, sdk.StatusError: return SeverityCrit case sdk.StatusWarn: return SeverityWarn case sdk.StatusInfo: return SeverityInfo default: return "" } } // overallStatus picks the overall badge label/class. When there are no // states at all (data-only render), we fall back to a neutral "data only" // badge instead of claiming "OK", we can't assert anything we haven't // actually evaluated. func overallStatus(d *SMTPData, states []sdk.CheckState, fixes []reportFix) (string, string) { if d.MX.NullMX { return "NULL MX", "info" } if len(states) == 0 { return "data only", "muted" } worst := "" for _, f := range fixes { if f.Severity == SeverityCrit { worst = SeverityCrit break } if f.Severity == SeverityWarn { worst = SeverityWarn } else if worst == "" && f.Severity == SeverityInfo { worst = SeverityInfo } } switch worst { case SeverityCrit: return "FAIL", "fail" case SeverityWarn: return "WARN", "warn" case SeverityInfo: return "INFO", "info" default: return "OK", "ok" } } func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture { out := map[string]*reportTLSPosture{} for _, r := range related { v := parseTLSRelated(r) if v == nil { continue } addr := v.address() if addr == "" { continue } posture := &reportTLSPosture{ CheckedAt: r.CollectedAt, ChainValid: v.ChainValid, HostnameMatch: v.HostnameMatch, NotAfter: v.NotAfter, } for _, is := range v.Issues { sev := strings.ToLower(is.Severity) if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo { continue } posture.Issues = append(posture.Issues, reportFix{ Severity: sev, Code: is.Code, Message: is.Message, Fix: is.Fix, }) } out[addr] = posture } return out }