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}}
| Pref | Target | IPv4 | IPv6 | Issues |
{{range .MX}}
| {{.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}}
|
{{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
-
{{.BannerCode}} {{.BannerLine}}
{{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
}