checker-email-autoconfig/checker/report.go

417 lines
13 KiB
Go

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.<domain>",
"wellknown": ".well-known/autoconfig",
"http-autoconfig": "http://autoconfig.<domain>",
"ispdb": "Mozilla ISPDB",
"mx-autoconfig": "MX-parent autoconfig",
"mx-ispdb": "MX-parent ISPDB",
"subdomain": "autodiscover.<domain>",
"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(`
<p>Clients such as Thunderbird, K-9 Mail, Evolution and KMail will query
<code>https://autoconfig.%[1]s/mail/config-v1.1.xml</code> when the user
types <code>user@%[1]s</code>. Publishing the file removes the need for
manual IMAP/SMTP setup.</p>
<ol>
<li>Create a subdomain <code>autoconfig.%[1]s</code> pointing to a TLS-enabled web server (a 200-byte static file is enough).</li>
<li>Drop the XML below at <code>/mail/config-v1.1.xml</code> and make sure it is served with <code>Content-Type: text/xml</code>.</li>
<li>Do the same at <code>https://%[1]s/.well-known/autoconfig/mail/config-v1.1.xml</code> so users who cannot add a subdomain still get configured.</li>
</ol>`, d.Domain)),
})
}
if hasParsed && !hasAutoconfig && hasWellKnown {
out = append(out, reportRemediation{
Title: "Add the autoconfig.<domain> subdomain",
Body: template.HTML(fmt.Sprintf(`
<p>Only the <code>.well-known</code> fallback responded. Thunderbird tries
<code>autoconfig.%[1]s</code> <em>first</em>, so adding the subdomain is a
cheap win. Copy the XML from your apex, expose it on
<code>https://autoconfig.%[1]s/mail/config-v1.1.xml</code>.</p>`, d.Domain)),
})
}
if plaintextOK {
out = append(out, reportRemediation{
Title: "Stop serving autoconfig over HTTP",
Body: template.HTML(`
<p>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.</p>`),
})
}
if tlsFailures > 0 {
out = append(out, reportRemediation{
Title: "Fix the TLS certificate on the autoconfig endpoint",
Body: template.HTML(fmt.Sprintf(`
<p>At least one autoconfig endpoint failed certificate verification
(expired, self-signed, hostname mismatch or unknown CA). Clients will
refuse the document outright.</p>
<p>Issue a certificate that covers <code>autoconfig.%[1]s</code> (and
<code>%[1]s</code> if you serve <code>.well-known</code>). Let's Encrypt
works out of the box.</p>`, 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(`
<p>The server <code>%s %s:%d</code> is advertised with
<code>socketType=%s</code>. Clients that apply your config will send the
password in clear. Switch to:</p>
<ul>
<li><code>SSL</code> on IMAP 993 / POP3 995 / SMTP submission 465.</li>
<li><code>STARTTLS</code> on IMAP 143 or SMTP submission 587.</li>
</ul>`, 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(`
<p>SRV records are a cheap safety net for clients that do not fetch an
autoconfig XML. Advertise IMAPS and submission:</p>
<pre>_imaps._tcp.%[1]s. IN SRV 0 1 993 imap.%[1]s.
_submissions._tcp.%[1]s. IN SRV 0 1 465 smtp.%[1]s.</pre>
<p>Use target <code>.</code> to explicitly declare a service as unsupported (e.g. <code>_pop3._tcp</code>).</p>`, 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 := `&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;clientConfig version=&quot;1.1&quot;&gt;
&lt;emailProvider id=&quot;%[1]s&quot;&gt;
&lt;domain&gt;%[1]s&lt;/domain&gt;
&lt;displayName&gt;%[1]s Mail&lt;/displayName&gt;
&lt;displayShortName&gt;%[1]s&lt;/displayShortName&gt;
&lt;incomingServer type=&quot;imap&quot;&gt;
&lt;hostname&gt;imap.%[1]s&lt;/hostname&gt;
&lt;port&gt;993&lt;/port&gt;
&lt;socketType&gt;SSL&lt;/socketType&gt;
&lt;username&gt;%%EMAILADDRESS%%&lt;/username&gt;
&lt;authentication&gt;password-cleartext&lt;/authentication&gt;
&lt;/incomingServer&gt;
&lt;outgoingServer type=&quot;smtp&quot;&gt;
&lt;hostname&gt;smtp.%[1]s&lt;/hostname&gt;
&lt;port&gt;465&lt;/port&gt;
&lt;socketType&gt;SSL&lt;/socketType&gt;
&lt;username&gt;%%EMAILADDRESS%%&lt;/username&gt;
&lt;authentication&gt;password-cleartext&lt;/authentication&gt;
&lt;/outgoingServer&gt;
&lt;/emailProvider&gt;
&lt;/clientConfig&gt;`
return fmt.Sprintf(tpl, domain)
}