package checker import ( "encoding/json" "fmt" "html/template" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) type reportStatus string const ( statusOK reportStatus = "ok" statusWarn reportStatus = "warn" statusFail reportStatus = "fail" statusInfo reportStatus = "info" statusSkip reportStatus = "skip" ) type reportProbe struct { ProbeResult Source string Verdict reportStatus VerdictText string } type reportServer struct { Type string Hostname string Port int SocketType string Authentication string Encrypted bool AuthSafe bool } type reportRemediation struct { Title string Body template.HTML } type reportData struct { Domain string Email string HeadlineBadge string HeadlineClass reportStatus HeadlineText string Summary []reportSummaryItem Autoconfig []reportProbe Autodiscover []reportProbe SRVRecords []SRVRecord MX []string ConfigServers struct { Incoming []reportServer Outgoing []reportServer CardDAV []DavServer CalDAV []DavServer } Remediations []reportRemediation ExampleXML template.HTML } type reportSummaryItem struct { Label string Status reportStatus Message string } // GetHTMLReport implements sdk.CheckerHTMLReporter. func (p *autoconfigProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var d Data if err := json.Unmarshal(ctx.Data(), &d); err != nil { return "", fmt.Errorf("decode autoconfig data: %w", err) } data := buildReport(&d) var buf strings.Builder buf.Grow(32 * 1024) if err := autoconfigHTMLTemplate.Execute(&buf, data); err != nil { return "", fmt.Errorf("render: %w", err) } return buf.String(), nil } func buildReport(d *Data) reportData { r := reportData{ Domain: d.Domain, Email: d.Email, } for _, m := range d.MX { r.MX = append(r.MX, fmt.Sprintf("%s (pref %d)", m.Host, m.Preference)) } hasParsed := false hasAutoconfigOK := false hasWellKnownOK := false plaintextOK := false tlsFailures := 0 for _, p := range d.Autoconfig { rp := reportProbe{ProbeResult: p.Result} switch { case p.Parsed != nil: rp.Verdict = statusOK rp.VerdictText = "Parsed OK" hasParsed = true if p.Source == "autoconfig" { hasAutoconfigOK = true } if p.Source == "wellknown" { hasWellKnownOK = true } if strings.HasPrefix(p.Result.URL, "http://") { plaintextOK = true } case p.Result.TLSError != "": rp.Verdict = statusFail rp.VerdictText = "TLS error" tlsFailures++ case p.Result.Error != "": rp.Verdict = statusFail rp.VerdictText = "Unreachable" case p.Result.ParseError != "": rp.Verdict = statusFail rp.VerdictText = "XML parse error" case p.Result.StatusCode >= 400: rp.Verdict = statusFail rp.VerdictText = fmt.Sprintf("HTTP %d", p.Result.StatusCode) default: rp.Verdict = statusWarn rp.VerdictText = fmt.Sprintf("HTTP %d (no config)", p.Result.StatusCode) } rp.Source = probeSourceLabel(p.Source) r.Autoconfig = append(r.Autoconfig, rp) } hasAutodiscover := false for _, p := range d.Autodiscover { rp := reportProbe{ProbeResult: p.Result} switch { case p.Parsed != nil: rp.Verdict = statusOK rp.VerdictText = "Parsed OK" hasAutodiscover = true case p.Result.Error != "": rp.Verdict = statusSkip rp.VerdictText = "Unreachable" case p.Result.ParseError != "": rp.Verdict = statusFail rp.VerdictText = "XML parse error" case p.Result.StatusCode >= 400: rp.Verdict = statusSkip rp.VerdictText = fmt.Sprintf("HTTP %d", p.Result.StatusCode) default: rp.Verdict = statusWarn rp.VerdictText = fmt.Sprintf("HTTP %d", p.Result.StatusCode) } rp.Source = probeSourceLabel(p.Source) r.Autodiscover = append(r.Autodiscover, rp) } hasIncomingSRV, hasSubmissionSRV := false, false for _, s := range d.SRV { if !s.Skip { inc, sub := classifySRV(s.Service) hasIncomingSRV = hasIncomingSRV || inc hasSubmissionSRV = hasSubmissionSRV || sub } } r.SRVRecords = d.SRV if d.ClientConfig != nil { for _, s := range d.ClientConfig.Incoming { r.ConfigServers.Incoming = append(r.ConfigServers.Incoming, toReportServer(s)) } for _, s := range d.ClientConfig.Outgoing { r.ConfigServers.Outgoing = append(r.ConfigServers.Outgoing, toReportServer(s)) } r.ConfigServers.CardDAV = d.ClientConfig.AddressBook r.ConfigServers.CalDAV = d.ClientConfig.Calendar } switch { case hasParsed && hasAutoconfigOK && tlsFailures == 0 && !plaintextOK: r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "OK", statusOK, "Email autoconfiguration is published and healthy." case hasParsed && tlsFailures == 0: r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "PARTIAL", statusWarn, "Autoconfig works but has room for improvement (see remediation)." case hasParsed && tlsFailures > 0: r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "TLS ISSUE", statusFail, "Autoconfig answers but at least one endpoint has a TLS problem." case hasAutodiscover: r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "LEGACY ONLY", statusWarn, "Only Microsoft Autodiscover answers. Thunderbird/K9/Evolution users will see manual setup." case hasIncomingSRV && hasSubmissionSRV: r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "SRV ONLY", statusWarn, "Only RFC 6186 SRV records; most clients still need a clientConfig XML." default: r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "NOT FOUND", statusFail, "No email autoconfiguration was discovered for this domain." } r.Summary = []reportSummaryItem{ {"Primary autoconfig (autoconfig." + d.Domain + ")", boolStatus(hasAutoconfigOK), primaryMsg(hasAutoconfigOK)}, {".well-known/autoconfig", boolStatus(hasWellKnownOK), wellKnownMsg(hasWellKnownOK)}, {"Microsoft Autodiscover", boolStatus(hasAutodiscover), autodiscoverMsg(hasAutodiscover)}, {"RFC 6186 SRV records", srvStatus(hasIncomingSRV, hasSubmissionSRV), srvMsg(hasIncomingSRV, hasSubmissionSRV)}, } r.Remediations = buildRemediations(d, hasParsed, hasAutoconfigOK, hasWellKnownOK, plaintextOK, tlsFailures, hasIncomingSRV, hasSubmissionSRV) r.ExampleXML = template.HTML(exampleClientConfig(d.Domain)) return r } func boolStatus(ok bool) reportStatus { if ok { return statusOK } return statusFail } func primaryMsg(ok bool) string { if ok { return "Reachable and serves a valid clientConfig." } return "Not reachable. This is the first URL Thunderbird tries." } func wellKnownMsg(ok bool) string { if ok { return "Answers on the domain apex." } return "Not exposed. Optional, but useful when you cannot add a subdomain." } func autodiscoverMsg(ok bool) string { if ok { return "Responds. Microsoft Outlook / mobile clients will find your server." } return "Silent. Outlook-for-Windows and iOS Mail rely on this." } func srvStatus(inc, sub bool) reportStatus { switch { case inc && sub: return statusOK case inc || sub: return statusWarn } return statusInfo } func srvMsg(inc, sub bool) string { switch { case inc && sub: return "Incoming and submission SRV records published." case inc: return "Submission SRV missing (_submissions._tcp)." case sub: return "Incoming SRV missing (_imaps._tcp / _pop3s._tcp)." } return "No RFC 6186 SRV records. Not mandatory, but a cheap safety net." } var probeLabelMap = map[string]string{ "autoconfig": "autoconfig.", "wellknown": ".well-known/autoconfig", "http-autoconfig": "http://autoconfig.", "ispdb": "Mozilla ISPDB", "mx-autoconfig": "MX-parent autoconfig", "mx-ispdb": "MX-parent ISPDB", "subdomain": "autodiscover.", "root": "apex /autodiscover", } func probeSourceLabel(src string) string { if label, ok := probeLabelMap[src]; ok { return label } return src } func toReportServer(s ServerConfig) reportServer { return reportServer{ Type: s.Type, Hostname: s.Hostname, Port: s.Port, SocketType: s.SocketType, Authentication: s.Authentication, Encrypted: isEncryptedSocket(s.SocketType), AuthSafe: !strings.EqualFold(s.Authentication, "password-cleartext") || isEncryptedSocket(s.SocketType), } } // ── Remediation snippets ──────────────────────────────────────────────────── func buildRemediations(d *Data, hasParsed, hasAutoconfig, hasWellKnown, plaintextOK bool, tlsFailures int, hasIncomingSRV, hasSubmissionSRV bool) []reportRemediation { var out []reportRemediation if !hasParsed { out = append(out, reportRemediation{ Title: "Publish an autoconfig XML file", Body: template.HTML(fmt.Sprintf(`

Clients such as Thunderbird, K-9 Mail, Evolution and KMail will query https://autoconfig.%[1]s/mail/config-v1.1.xml when the user types user@%[1]s. Publishing the file removes the need for manual IMAP/SMTP setup.

  1. Create a subdomain autoconfig.%[1]s pointing to a TLS-enabled web server (a 200-byte static file is enough).
  2. Drop the XML below at /mail/config-v1.1.xml and make sure it is served with Content-Type: text/xml.
  3. Do the same at https://%[1]s/.well-known/autoconfig/mail/config-v1.1.xml so users who cannot add a subdomain still get configured.
`, d.Domain)), }) } if hasParsed && !hasAutoconfig && hasWellKnown { out = append(out, reportRemediation{ Title: "Add the autoconfig. subdomain", Body: template.HTML(fmt.Sprintf(`

Only the .well-known fallback responded. Thunderbird tries autoconfig.%[1]s first, so adding the subdomain is a cheap win. Copy the XML from your apex, expose it on https://autoconfig.%[1]s/mail/config-v1.1.xml.

`, d.Domain)), }) } if plaintextOK { out = append(out, reportRemediation{ Title: "Stop serving autoconfig over HTTP", Body: template.HTML(`

The draft requires clients to ignore plaintext responses unless HTTPS fails. Clients will still warn the user. Redirect HTTP to HTTPS and drop the plaintext virtualhost entirely.

`), }) } if tlsFailures > 0 { out = append(out, reportRemediation{ Title: "Fix the TLS certificate on the autoconfig endpoint", Body: template.HTML(fmt.Sprintf(`

At least one autoconfig endpoint failed certificate verification (expired, self-signed, hostname mismatch or unknown CA). Clients will refuse the document outright.

Issue a certificate that covers autoconfig.%[1]s (and %[1]s if you serve .well-known). Let's Encrypt works out of the box.

`, d.Domain)), }) } if d.ClientConfig != nil { servers := make([]ServerConfig, 0, len(d.ClientConfig.Incoming)+len(d.ClientConfig.Outgoing)) servers = append(servers, d.ClientConfig.Incoming...) servers = append(servers, d.ClientConfig.Outgoing...) for _, s := range servers { if !isEncryptedSocket(s.SocketType) { out = append(out, reportRemediation{ Title: "Remove plaintext server definitions", Body: template.HTML(fmt.Sprintf(`

The server %s %s:%d is advertised with socketType=%s. Clients that apply your config will send the password in clear. Switch to:

  • SSL on IMAP 993 / POP3 995 / SMTP submission 465.
  • STARTTLS on IMAP 143 or SMTP submission 587.
`, s.Type, s.Hostname, s.Port, s.SocketType)), }) break } } } if !hasIncomingSRV || !hasSubmissionSRV { out = append(out, reportRemediation{ Title: "Publish RFC 6186 SRV records", Body: template.HTML(fmt.Sprintf(`

SRV records are a cheap safety net for clients that do not fetch an autoconfig XML. Advertise IMAPS and submission:

_imaps._tcp.%[1]s.        IN SRV 0 1 993 imap.%[1]s.
_submissions._tcp.%[1]s.  IN SRV 0 1 465 smtp.%[1]s.

Use target . to explicitly declare a service as unsupported (e.g. _pop3._tcp).

`, d.Domain)), }) } return out } // exampleClientConfig returns a paste-ready XML snippet for the domain. func exampleClientConfig(domain string) string { if domain == "" { domain = "example.com" } tpl := `<?xml version="1.0" encoding="UTF-8"?> <clientConfig version="1.1"> <emailProvider id="%[1]s"> <domain>%[1]s</domain> <displayName>%[1]s Mail</displayName> <displayShortName>%[1]s</displayShortName> <incomingServer type="imap"> <hostname>imap.%[1]s</hostname> <port>993</port> <socketType>SSL</socketType> <username>%%EMAILADDRESS%%</username> <authentication>password-cleartext</authentication> </incomingServer> <outgoingServer type="smtp"> <hostname>smtp.%[1]s</hostname> <port>465</port> <socketType>SSL</socketType> <username>%%EMAILADDRESS%%</username> <authentication>password-cleartext</authentication> </outgoingServer> </emailProvider> </clientConfig>` return fmt.Sprintf(tpl, domain) }