Initial commit

This commit is contained in:
nemunaire 2026-04-23 17:45:37 +07:00
commit 2d6cd4be33
19 changed files with 2451 additions and 0 deletions

450
checker/collect.go Normal file
View file

@ -0,0 +1,450 @@
package checker
import (
"context"
"crypto/tls"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Real autoconfig/autodiscover documents are tiny; anything bigger is
// misconfigured or hostile.
const maxBodyBytes = 256 * 1024
func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain_name")
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
if domain == "" {
return nil, fmt.Errorf("domain_name is required")
}
domain, err := validateDomain(domain)
if err != nil {
return nil, err
}
localPart, _ := sdk.GetOption[string](opts, "probeEmail")
if localPart == "" {
localPart = "test"
}
email := localPart + "@" + domain
httpTimeout := time.Duration(sdk.GetFloatOption(opts, "httpTimeout", 8)) * time.Second
if httpTimeout <= 0 {
httpTimeout = 8 * time.Second
}
ispdbURL, _ := sdk.GetOption[string](opts, "ispdbURL")
if ispdbURL == "" {
ispdbURL = "https://autoconfig.thunderbird.net/v1.1/"
}
if !strings.HasSuffix(ispdbURL, "/") {
ispdbURL += "/"
}
ispdbParsed, err := url.Parse(ispdbURL)
if err != nil || (ispdbParsed.Scheme != "http" && ispdbParsed.Scheme != "https") || ispdbParsed.Host == "" {
return nil, fmt.Errorf("invalid ispdbURL: must be an absolute http(s) URL")
}
if _, err := validateDomain(ispdbParsed.Hostname()); err != nil {
return nil, fmt.Errorf("invalid ispdbURL host: %w", err)
}
userAgent, _ := sdk.GetOption[string](opts, "userAgent")
if userAgent == "" {
userAgent = "happyDomain-autoconfig/1.0 (+https://happydomain.org)"
}
tryISPDB := sdk.GetBoolOption(opts, "tryISPDB", true)
tryHTTPAutoconfig := sdk.GetBoolOption(opts, "tryHTTPAutoconfig", false)
tryAutodiscover := sdk.GetBoolOption(opts, "tryAutodiscoverPost", true)
client := newHTTPClient(httpTimeout)
data := &Data{
Domain: domain,
Email: email,
CollectedAt: time.Now().UTC(),
}
// SRV and Autodiscover are independent of MX; run them in background.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
data.SRV = collectSRV(ctx, domain, httpTimeout)
}()
if tryAutodiscover {
wg.Add(1)
go func() {
defer wg.Done()
data.Autodiscover = collectAutodiscover(ctx, client, userAgent, domain, email)
for _, p := range data.Autodiscover {
if p.Parsed != nil {
data.AutodiscoverResult = p.Parsed
break
}
}
}()
}
// MX lookup: result feeds into collectAutoconfig below.
mxResolveCtx, cancel := context.WithTimeout(ctx, httpTimeout)
mx, mxErr := net.DefaultResolver.LookupMX(mxResolveCtx, domain)
cancel()
if mxErr != nil {
data.MXError = mxErr.Error()
}
for _, r := range mx {
data.MX = append(data.MX, MXRecord{
Host: strings.TrimSuffix(r.Host, "."),
Preference: r.Pref,
})
}
// Runs synchronously: needs MX, but overlaps the SRV/Autodiscover goroutines.
data.Autoconfig = collectAutoconfig(ctx, client, userAgent, domain, email, data.MX, tryISPDB, tryHTTPAutoconfig, ispdbURL)
for _, p := range data.Autoconfig {
if p.Parsed != nil {
data.ClientConfig = p.Parsed
data.ClientConfigSource = p.Source
break
}
}
wg.Wait()
return data, nil
}
func newHTTPClient(timeout time.Duration) *http.Client {
// Keep cert validation ON; failures are surfaced as soft probe errors
// so the rule engine can flag them.
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 8,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
return &http.Client{
Transport: tr,
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return http.ErrUseLastResponse
}
return nil
},
}
}
func fetch(ctx context.Context, client *http.Client, userAgent, method, rawURL string, body io.Reader, contentType string) (ProbeResult, []byte) {
res := ProbeResult{URL: rawURL, Method: method}
start := time.Now()
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
res.Error = err.Error()
res.DurationMs = time.Since(start).Milliseconds()
return res, nil
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/xml, text/xml, */*;q=0.8")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := client.Do(req)
res.DurationMs = time.Since(start).Milliseconds()
if err != nil {
res.Error = err.Error()
var tlsErr *tls.CertificateVerificationError
if errors.As(err, &tlsErr) {
res.TLSError = err.Error()
}
return res, nil
}
defer resp.Body.Close()
res.StatusCode = resp.StatusCode
res.ContentType = resp.Header.Get("Content-Type")
res.FinalURL = resp.Request.URL.String()
res.Redirected = res.FinalURL != rawURL
if resp.TLS != nil {
res.TLSServerName = resp.TLS.ServerName
if len(resp.TLS.PeerCertificates) > 0 {
leaf := resp.TLS.PeerCertificates[0]
res.TLSSubject = leaf.Subject.CommonName
res.TLSIssuer = leaf.Issuer.CommonName
res.TLSNotAfter = leaf.NotAfter.UTC().Format(time.RFC3339)
}
}
limit := io.LimitReader(resp.Body, maxBodyBytes+1)
raw, rerr := io.ReadAll(limit)
if rerr != nil {
res.Error = rerr.Error()
return res, nil
}
res.BodyBytes = len(raw)
if len(raw) > maxBodyBytes {
res.Error = fmt.Sprintf("response truncated at %d bytes", maxBodyBytes)
raw = raw[:maxBodyBytes]
}
return res, raw
}
func collectAutoconfig(ctx context.Context, client *http.Client, userAgent, domain, email string, mx []MXRecord, tryISPDB, tryHTTPAutoconfig bool, ispdbURL string) []AutoconfigProbe {
encoded := url.QueryEscape(email)
type target struct {
source string
url string
}
targets := []target{
{"autoconfig", fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml?emailaddress=%s", domain, encoded)},
{"wellknown", fmt.Sprintf("https://%s/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=%s", domain, encoded)},
}
if tryHTTPAutoconfig {
targets = append(targets, target{"http-autoconfig", fmt.Sprintf("http://autoconfig.%s/mail/config-v1.1.xml?emailaddress=%s", domain, encoded)})
}
if tryISPDB {
targets = append(targets, target{"ispdb", ispdbURL + domain})
}
// MX fallback catches gmail/MS365-hosted domains. Bucksch suggests
// iterating every MX; the lowest-preference one is enough in practice.
if mxParent := pickMXParent(mx); mxParent != "" && mxParent != domain {
targets = append(targets, target{"mx-autoconfig", fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml?emailaddress=%s", mxParent, encoded)})
if tryISPDB {
targets = append(targets, target{"mx-ispdb", ispdbURL + mxParent})
}
}
probes := make([]AutoconfigProbe, len(targets))
var wg sync.WaitGroup
for i, t := range targets {
wg.Add(1)
go func(i int, source, rawURL string) {
defer wg.Done()
probes[i] = runAutoconfigProbe(ctx, client, userAgent, source, rawURL)
}(i, t.source, t.url)
}
wg.Wait()
return probes
}
func runAutoconfigProbe(ctx context.Context, client *http.Client, userAgent, source, rawURL string) AutoconfigProbe {
res, body := fetch(ctx, client, userAgent, http.MethodGet, rawURL, nil, "")
probe := AutoconfigProbe{Source: source, Result: res}
if res.Error != "" || res.StatusCode < 200 || res.StatusCode >= 300 || len(body) == 0 {
return probe
}
cfg, err := parseClientConfig(body)
if err != nil {
probe.Result.ParseError = err.Error()
return probe
}
probe.Parsed = cfg
return probe
}
// validateDomain rejects anything that could escape URL interpolation
// (path/query injection, IP literals). IP-range filtering is left to the
// network layer.
func validateDomain(domain string) (string, error) {
domain = strings.ToLower(domain)
if len(domain) == 0 || len(domain) > 253 {
return "", fmt.Errorf("invalid domain name: length must be 1..253")
}
if net.ParseIP(domain) != nil {
return "", fmt.Errorf("invalid domain name: IP literals are not accepted")
}
labels := strings.Split(domain, ".")
if len(labels) < 2 {
return "", fmt.Errorf("invalid domain name: must contain at least one dot")
}
for _, label := range labels {
if len(label) == 0 || len(label) > 63 {
return "", fmt.Errorf("invalid domain name: label length must be 1..63")
}
if label[0] == '-' || label[len(label)-1] == '-' {
return "", fmt.Errorf("invalid domain name: label %q cannot start or end with '-'", label)
}
for i := 0; i < len(label); i++ {
c := label[i]
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
return "", fmt.Errorf("invalid domain name: label %q contains forbidden character", label)
}
}
}
return domain, nil
}
// pickMXParent returns the parent domain of the lowest-preference MX, or
// empty when no suitable MX is present.
func pickMXParent(mx []MXRecord) string {
if len(mx) == 0 {
return ""
}
best := mx[0]
for _, r := range mx[1:] {
if r.Preference < best.Preference {
best = r
}
}
return registrableDomain(best.Host)
}
// registrableDomain approximates a PSL lookup with last-two-labels (or
// three when the SLD looks like a ccTLD second level, e.g. co.uk). Good
// enough for the gmail / MS365 MX-fallback case we actually care about.
func registrableDomain(host string) string {
host = strings.TrimSuffix(strings.ToLower(host), ".")
parts := strings.Split(host, ".")
if len(parts) < 2 {
return host
}
n := 2
// Very rough country-code second-level heuristic.
if len(parts) >= 3 && len(parts[len(parts)-2]) <= 3 && len(parts[len(parts)-1]) == 2 {
n = 3
}
if len(parts) < n {
return host
}
return strings.Join(parts[len(parts)-n:], ".")
}
// ── RFC 6186 SRV ─────────────────────────────────────────────────────────────
var rfc6186Services = []string{
"_imaps._tcp",
"_imap._tcp",
"_pop3s._tcp",
"_pop3._tcp",
"_submissions._tcp",
"_submission._tcp",
"_autodiscover._tcp",
}
func collectSRV(ctx context.Context, domain string, timeout time.Duration) []SRVRecord {
type indexedResult struct {
idx int
recs []SRVRecord
}
ch := make(chan indexedResult, len(rfc6186Services))
for i, svc := range rfc6186Services {
go func(idx int, svc string) {
c, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
_, addrs, err := net.DefaultResolver.LookupSRV(c, "", "", svc+"."+domain)
if err != nil {
ch <- indexedResult{idx, nil}
return
}
var recs []SRVRecord
for _, a := range addrs {
target := strings.TrimSuffix(a.Target, ".")
rec := SRVRecord{
Service: svc,
Target: target,
Port: a.Port,
Priority: a.Priority,
Weight: a.Weight,
}
// RFC 2782 "service not provided at this domain" sentinel.
if target == "" || target == "." {
rec.Skip = true
}
recs = append(recs, rec)
}
ch <- indexedResult{idx, recs}
}(i, svc)
}
results := make([][]SRVRecord, len(rfc6186Services))
for range rfc6186Services {
r := <-ch
results[r.idx] = r.recs
}
var out []SRVRecord
for _, recs := range results {
out = append(out, recs...)
}
return out
}
// ── Microsoft Autodiscover (POX) ─────────────────────────────────────────────
const autodiscoverRequestTemplate = `<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
<Request>
<EMailAddress>%s</EMailAddress>
<AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
</Request>
</Autodiscover>`
func collectAutodiscover(ctx context.Context, client *http.Client, userAgent, domain, email string) []AutodiscoverProbe {
body := fmt.Sprintf(autodiscoverRequestTemplate, xmlEscape(email))
type target struct {
source string
url string
}
targets := []target{
{"subdomain", fmt.Sprintf("https://autodiscover.%s/autodiscover/autodiscover.xml", domain)},
{"root", fmt.Sprintf("https://%s/autodiscover/autodiscover.xml", domain)},
}
probes := make([]AutodiscoverProbe, len(targets))
var wg sync.WaitGroup
for i, t := range targets {
wg.Add(1)
go func(i int, source, rawURL string) {
defer wg.Done()
probes[i] = runAutodiscoverProbe(ctx, client, userAgent, source, rawURL, body)
}(i, t.source, t.url)
}
wg.Wait()
return probes
}
func runAutodiscoverProbe(ctx context.Context, client *http.Client, userAgent, source, rawURL, requestBody string) AutodiscoverProbe {
res, body := fetch(ctx, client, userAgent, http.MethodPost, rawURL, strings.NewReader(requestBody), "text/xml; charset=utf-8")
probe := AutodiscoverProbe{Source: source, Result: res}
if res.Error != "" || res.StatusCode < 200 || res.StatusCode >= 300 || len(body) == 0 {
return probe
}
parsed, err := parseAutodiscoverResponse(body)
if err != nil {
probe.Result.ParseError = err.Error()
return probe
}
probe.Parsed = parsed
return probe
}
func xmlEscape(s string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}

100
checker/definition.go Normal file
View file

@ -0,0 +1,100 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is overridden at link time by the build, or by the plugin loader.
var Version = "built-in"
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "email-autoconfig",
Name: "Email Autoconfiguration",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyAutoconfig},
HasHTMLReport: true,
Options: sdk.CheckerOptionsDocumentation{
DomainOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Label: "Domain name",
AutoFill: sdk.AutoFillDomainName,
Required: true,
},
},
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "probeEmail",
Type: "string",
Label: "Local-part used in probes",
Description: "Local part sent in the autoconfig URL query string (before @). Most servers ignore it, but some providers branch on the user.",
Default: "test",
},
{
Id: "httpTimeout",
Type: "number",
Label: "HTTP timeout (seconds)",
Description: "Per-request timeout when probing autoconfig endpoints.",
Default: float64(8),
},
{
Id: "tryISPDB",
Type: "bool",
Label: "Try Mozilla ISPDB fallback",
Description: "When the domain itself does not publish an autoconfig file, try Mozilla's public Thunderbird ISPDB as an additional probe.",
Default: true,
},
{
Id: "tryHTTPAutoconfig",
Type: "bool",
Label: "Allow plain-HTTP fallback probe",
Description: "Also attempt the plain-HTTP variant of autoconfig.<domain> (the draft lists it as optional). Useful to spot providers still serving over HTTP.",
Default: false,
},
{
Id: "tryAutodiscoverPost",
Type: "bool",
Label: "Probe Microsoft Autodiscover (POST)",
Description: "Probe the Exchange/Outlook Autodiscover endpoints. Disable to check only the Thunderbird flow.",
Default: true,
},
},
AdminOpts: []sdk.CheckerOptionDocumentation{
{
Id: "ispdbURL",
Type: "string",
Label: "Mozilla ISPDB base URL",
Default: "https://autoconfig.thunderbird.net/v1.1/",
Description: "Base URL for Mozilla's autoconfig fallback database.",
},
{
Id: "userAgent",
Type: "string",
Label: "User-Agent used in probes",
Default: "happyDomain-autoconfig/1.0 (+https://happydomain.org)",
Description: "Identifies the checker in probe HTTP logs.",
},
},
},
Rules: []sdk.CheckRule{
PresenceRule(),
PreferredEndpointRule(),
TLSRule(),
EncryptionRule(),
ConsistencyRule(),
SRVRule(),
AutodiscoverRule(),
},
Interval: &sdk.CheckIntervalSpec{
Min: 15 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
}
}

85
checker/interactive.go Normal file
View file

@ -0,0 +1,85 @@
//go:build standalone
package checker
import (
"errors"
"fmt"
"net/http"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm describes the standalone /check page inputs.
func (p *autoconfigProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: sdk.AutoFillDomainName,
Type: "string",
Label: "Domain name",
Placeholder: "example.com",
Required: true,
Description: "Domain to probe for email autoconfiguration.",
},
{
Id: "probeEmail",
Type: "string",
Label: "Local-part used in probes",
Placeholder: "test",
Default: "test",
Description: "Local part sent in the autoconfig URL query string (before @).",
},
{
Id: "tryISPDB",
Type: "bool",
Label: "Try Mozilla ISPDB fallback",
Default: true,
Description: "Probe Mozilla's public Thunderbird ISPDB when the domain itself does not publish autoconfig.",
},
{
Id: "tryHTTPAutoconfig",
Type: "bool",
Label: "Allow plain-HTTP fallback probe",
Default: false,
Description: "Also attempt the plain-HTTP variant of autoconfig.<domain>.",
},
{
Id: "tryAutodiscoverPost",
Type: "bool",
Label: "Probe Microsoft Autodiscover (POST)",
Default: true,
Description: "Exercise Exchange/Outlook Autodiscover endpoints as well.",
},
}
}
// ParseForm builds CheckerOptions from the submitted form. All probing is
// deferred to Collect, so we only validate the domain shape here.
func (p *autoconfigProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
if err := r.ParseForm(); err != nil {
return nil, fmt.Errorf("parse form: %w", err)
}
domain := strings.TrimSuffix(strings.TrimSpace(r.FormValue(sdk.AutoFillDomainName)), ".")
if domain == "" {
return nil, errors.New("domain name is required")
}
opts := sdk.CheckerOptions{
sdk.AutoFillDomainName: domain,
}
if v := strings.TrimSpace(r.FormValue("probeEmail")); v != "" {
opts["probeEmail"] = v
}
// HTML omits unchecked boxes; treat absence as "use the documented default".
for _, key := range []string{"tryISPDB", "tryHTTPAutoconfig", "tryAutodiscoverPost"} {
if _, ok := r.Form[key]; !ok {
continue
}
opts[key] = r.FormValue(key) == "true"
}
return opts, nil
}

202
checker/parse.go Normal file
View file

@ -0,0 +1,202 @@
package checker
import (
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
)
// ── Thunderbird autoconfig (clientConfig) ────────────────────────────────────
type rawClientConfig struct {
XMLName xml.Name `xml:"clientConfig"`
Version string `xml:"version,attr"`
EmailProvider rawEmailProvider `xml:"emailProvider"`
}
type rawEmailProvider struct {
ID string `xml:"id,attr"`
DisplayName string `xml:"displayName"`
ShortName string `xml:"displayShortName"`
Domains []string `xml:"domain"`
Incoming []rawServer `xml:"incomingServer"`
Outgoing []rawServer `xml:"outgoingServer"`
AddressBook []rawDav `xml:"addressBook"`
Calendar []rawDav `xml:"calendar"`
WebMail *rawWebMail `xml:"webMail"`
Documentation []rawDocuments `xml:"documentation"`
}
type rawServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port string `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
}
type rawDav struct {
Type string `xml:"type,attr"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
ServerURL string `xml:"serverURL"`
}
type rawWebMail struct {
LoginPage struct {
URL string `xml:"url,attr"`
} `xml:"loginPage"`
}
type rawDocuments struct {
URL string `xml:"url,attr"`
Descr string `xml:"descr"`
}
// parseClientConfig decodes a clientConfig document.
func parseClientConfig(body []byte) (*ClientConfig, error) {
body = bytes.TrimSpace(body)
if len(body) == 0 {
return nil, fmt.Errorf("empty body")
}
// Cheap reject before invoking the XML decoder.
if !bytes.Contains(body, []byte("<clientConfig")) {
return nil, fmt.Errorf("not a clientConfig document")
}
var raw rawClientConfig
if err := xml.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("XML decode: %w", err)
}
if len(raw.EmailProvider.Incoming) == 0 && len(raw.EmailProvider.Outgoing) == 0 {
return nil, fmt.Errorf("no incoming/outgoing server defined")
}
cfg := &ClientConfig{
Version: raw.Version,
EmailProviderID: raw.EmailProvider.ID,
DisplayName: raw.EmailProvider.DisplayName,
ShortName: raw.EmailProvider.ShortName,
Domains: raw.EmailProvider.Domains,
}
for _, s := range raw.EmailProvider.Incoming {
cfg.Incoming = append(cfg.Incoming, convertServer(s))
}
for _, s := range raw.EmailProvider.Outgoing {
cfg.Outgoing = append(cfg.Outgoing, convertServer(s))
}
for _, d := range raw.EmailProvider.AddressBook {
cfg.AddressBook = append(cfg.AddressBook, convertDav(d))
}
for _, d := range raw.EmailProvider.Calendar {
cfg.Calendar = append(cfg.Calendar, convertDav(d))
}
if raw.EmailProvider.WebMail != nil && raw.EmailProvider.WebMail.LoginPage.URL != "" {
cfg.WebMail = &WebMail{LoginPage: raw.EmailProvider.WebMail.LoginPage.URL}
}
for _, d := range raw.EmailProvider.Documentation {
cfg.Documentation = append(cfg.Documentation, Documentation{URL: d.URL, Descr: d.Descr})
}
return cfg, nil
}
func convertDav(d rawDav) DavServer {
return DavServer{
Type: d.Type,
Username: d.Username,
Authentication: d.Authentication,
ServerURL: d.ServerURL,
}
}
func convertServer(s rawServer) ServerConfig {
port, _ := strconv.Atoi(strings.TrimSpace(s.Port))
return ServerConfig{
Type: strings.ToLower(strings.TrimSpace(s.Type)),
Hostname: strings.TrimSpace(s.Hostname),
Port: port,
SocketType: strings.TrimSpace(s.SocketType),
Username: strings.TrimSpace(s.Username),
Authentication: strings.TrimSpace(s.Authentication),
}
}
// ── Microsoft Autodiscover POX response ──────────────────────────────────────
type rawAutodiscover struct {
XMLName xml.Name `xml:"Autodiscover"`
Response rawADResponse `xml:"Response"`
}
type rawADResponse struct {
User rawADUser `xml:"User"`
Account rawADAccount `xml:"Account"`
}
type rawADUser struct {
DisplayName string `xml:"DisplayName"`
}
type rawADAccount struct {
Action string `xml:"Action"`
RedirectAddr string `xml:"RedirectAddr"`
RedirectURL string `xml:"RedirectUrl"`
Protocols []rawADProto `xml:"Protocol"`
}
type rawADProto struct {
Type string `xml:"Type"`
Server string `xml:"Server"`
Port string `xml:"Port"`
Encryption string `xml:"Encryption"`
SSL string `xml:"SSL"`
LoginName string `xml:"LoginName"`
DomainRequired string `xml:"DomainRequired"`
AuthRequired string `xml:"AuthRequired"`
}
func parseAutodiscoverResponse(body []byte) (*AutodiscoverResponse, error) {
body = bytes.TrimSpace(body)
if len(body) == 0 {
return nil, fmt.Errorf("empty body")
}
if !bytes.Contains(body, []byte("Autodiscover")) {
return nil, fmt.Errorf("not an Autodiscover document")
}
var raw rawAutodiscover
dec := xml.NewDecoder(bytes.NewReader(body))
// Exchange responses are namespaced; we accept any.
dec.Strict = false
if err := dec.Decode(&raw); err != nil {
return nil, fmt.Errorf("XML decode: %w", err)
}
r := &AutodiscoverResponse{
DisplayName: strings.TrimSpace(raw.Response.User.DisplayName),
RedirectAddr: strings.TrimSpace(raw.Response.Account.RedirectAddr),
RedirectURL: strings.TrimSpace(raw.Response.Account.RedirectURL),
}
for _, p := range raw.Response.Account.Protocols {
port, _ := strconv.Atoi(strings.TrimSpace(p.Port))
r.Protocols = append(r.Protocols, AutodiscoverProtocol{
Type: strings.ToUpper(strings.TrimSpace(p.Type)),
Server: strings.TrimSpace(p.Server),
Port: port,
Encryption: strings.TrimSpace(p.Encryption),
SSL: strings.TrimSpace(p.SSL),
LoginName: strings.TrimSpace(p.LoginName),
DomainRequired: strings.TrimSpace(p.DomainRequired),
AuthRequired: strings.TrimSpace(p.AuthRequired),
})
}
// A bare redirect is valid; accept it.
if len(r.Protocols) == 0 && r.RedirectAddr == "" && r.RedirectURL == "" {
return nil, fmt.Errorf("Autodiscover response has no protocol or redirect")
}
return r, nil
}

19
checker/provider.go Normal file
View file

@ -0,0 +1,19 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
func Provider() sdk.ObservationProvider {
return &autoconfigProvider{}
}
type autoconfigProvider struct{}
func (p *autoconfigProvider) Key() sdk.ObservationKey {
return ObservationKeyAutoconfig
}
func (p *autoconfigProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

417
checker/report.go Normal file
View file

@ -0,0 +1,417 @@
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)
}

269
checker/report_template.go Normal file
View file

@ -0,0 +1,269 @@
package checker
import "html/template"
var templateFuncs = template.FuncMap{
"string": func(s reportStatus) string { return string(s) },
"badgeClass": func(s reportStatus) string {
switch s {
case statusOK:
return "badge-ok"
case statusWarn:
return "badge-warn"
case statusFail:
return "badge-fail"
case statusInfo:
return "badge-info"
}
return "badge-skip"
},
"chkClass": func(s reportStatus) string {
switch s {
case statusOK:
return "chk-ok"
case statusWarn:
return "chk-warn"
case statusFail:
return "chk-fail"
case statusInfo:
return "chk-info"
}
return "chk-skip"
},
"chkIcon": func(s reportStatus) string {
switch s {
case statusOK:
return "✓"
case statusWarn:
return "!"
case statusFail:
return "✗"
case statusInfo:
return "i"
}
return "·"
},
}
var autoconfigHTMLTemplate = template.Must(template.New("autoconfig").Funcs(templateFuncs).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Autoconfiguration Report: {{.Domain}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
pre { background: #f3f4f6; padding: .6rem .85rem; border-radius: 6px; overflow-x: auto; font-size: .82rem; }
h1 { margin: 0 0 .4rem; font-size: 1.25rem; }
h2 { margin: 0 0 .6rem; font-size: 1rem; }
h3 { margin: 0 0 .4rem; font-size: .9rem; }
.hd, .section { background: #fff; border-radius: 10px; padding: 1rem 1.2rem; margin-bottom: .75rem; box-shadow: 0 1px 3px rgba(0,0,0,.07); }
.badge { display: inline-flex; align-items: center; padding: .2em .7em; border-radius: 9999px; font-size: .78rem; font-weight: 700; letter-spacing: .02em; }
.badge-ok { background: #d1fae5; color: #065f46; }
.badge-warn { background: #fef3c7; color: #92400e; }
.badge-fail { background: #fee2e2; color: #991b1b; }
.badge-info { background: #dbeafe; color: #1e40af; }
.badge-skip { background: #e5e7eb; color: #4b5563; }
.summary-grid { display: grid; grid-template-columns: minmax(200px, auto) 1fr; row-gap: .4rem; column-gap: 1rem; align-items: center; }
.summary-grid > .s-label { font-weight: 600; font-size: .9rem; }
.summary-grid > .s-text { font-size: .87rem; color: #4b5563; }
details { border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: .45rem; overflow: hidden; }
summary { display: flex; gap: .5rem; align-items: center; padding: .55rem .85rem; cursor: pointer; user-select: none; }
summary::-webkit-details-marker { display: none; }
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; }
details[open] > summary::before { transform: rotate(90deg); }
.probe-source { font-weight: 600; flex: 1; }
.probe-url { color: #6b7280; font-size: .78rem; word-break: break-all; }
.details-body { padding: .5rem 1rem .8rem; border-top: 1px solid #f3f4f6; font-size: .85rem; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; margin-top: .25rem; font-size: .82rem; }
.kv > dt { color: #6b7280; font-weight: 600; }
.kv > dd { margin: 0; word-break: break-all; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .35rem .55rem; border-bottom: 1px solid #f3f4f6; }
th { color: #6b7280; font-weight: 600; }
.row-strike { color: #9ca3af; text-decoration: line-through; }
.tip { border-left: 4px solid #3b82f6; padding: .75rem 1rem; background: #eff6ff; border-radius: 0 6px 6px 0; margin-bottom: .6rem; }
.tip-title { font-weight: 700; margin-bottom: .3rem; }
.tip p, .tip ul, .tip ol, .tip pre { margin: .3rem 0; }
.chk { margin-right: .3rem; }
.chk-ok { color: #059669; }
.chk-warn { color: #b45309; }
.chk-fail { color: #dc2626; }
.chk-skip { color: #6b7280; }
.chk-info { color: #1e40af; }
.mini { color: #6b7280; font-size: .82rem; }
</style>
</head>
<body>
<div class="hd">
<h1>Email Autoconfiguration: <code>{{.Domain}}</code></h1>
<span class="badge {{badgeClass .HeadlineClass}}">{{.HeadlineBadge}}</span>
<p class="mini">{{.HeadlineText}}</p>
{{if .MX}}<p class="mini">MX: {{range $i, $m := .MX}}{{if $i}}, {{end}}<code>{{$m}}</code>{{end}}</p>{{end}}
</div>
<div class="section">
<h2>Summary</h2>
<div class="summary-grid">
{{range .Summary}}
<div class="s-label">
<span class="chk {{chkClass .Status}}">{{chkIcon .Status}}</span>{{.Label}}
</div>
<div class="s-text">{{.Message}}</div>
{{end}}
</div>
</div>
{{if .Remediations}}
<div class="section">
<h2>Fix this first</h2>
{{range .Remediations}}
<div class="tip">
<div class="tip-title">{{.Title}}</div>
{{.Body}}
</div>
{{end}}
</div>
{{end}}
<div class="section">
<h2>Thunderbird-style probes ({{len .Autoconfig}})</h2>
{{range .Autoconfig}}
<details{{if eq (string .Verdict) "fail"}} open{{end}}>
<summary>
<span class="probe-source">{{.Source}}</span>
<span class="probe-url"><code>{{.URL}}</code></span>
<span class="badge {{badgeClass .Verdict}}">{{.VerdictText}}</span>
</summary>
<div class="details-body">
<dl class="kv">
{{if .StatusCode}}<dt>HTTP</dt><dd>{{.StatusCode}}</dd>{{end}}
{{if .ContentType}}<dt>Content-Type</dt><dd>{{.ContentType}}</dd>{{end}}
{{if .DurationMs}}<dt>Duration</dt><dd>{{.DurationMs}} ms</dd>{{end}}
{{if .BodyBytes}}<dt>Body size</dt><dd>{{.BodyBytes}} bytes</dd>{{end}}
{{if .Redirected}}<dt>Final URL</dt><dd><code>{{.FinalURL}}</code></dd>{{end}}
{{if .TLSSubject}}<dt>TLS subject</dt><dd>{{.TLSSubject}}</dd>{{end}}
{{if .TLSIssuer}}<dt>TLS issuer</dt><dd>{{.TLSIssuer}}</dd>{{end}}
{{if .TLSNotAfter}}<dt>Expires</dt><dd>{{.TLSNotAfter}}</dd>{{end}}
{{if .TLSError}}<dt>TLS error</dt><dd style="color:#dc2626">{{.TLSError}}</dd>{{end}}
{{if .Error}}<dt>Error</dt><dd style="color:#dc2626">{{.Error}}</dd>{{end}}
{{if .ParseError}}<dt>Parse error</dt><dd style="color:#dc2626">{{.ParseError}}</dd>{{end}}
</dl>
</div>
</details>
{{end}}
</div>
{{if .ConfigServers.Incoming}}
<div class="section">
<h2>Servers advertised by clientConfig</h2>
<h3>Incoming</h3>
<table>
<tr><th>Type</th><th>Hostname</th><th>Port</th><th>Socket</th><th>Auth</th></tr>
{{range .ConfigServers.Incoming}}
<tr>
<td>{{.Type}}</td>
<td><code>{{.Hostname}}</code></td>
<td>{{.Port}}</td>
<td>{{if .Encrypted}}<span class="chk-ok">{{.SocketType}}</span>{{else}}<span class="chk-fail">{{.SocketType}}</span>{{end}}</td>
<td>{{if .AuthSafe}}{{.Authentication}}{{else}}<span class="chk-fail">{{.Authentication}}</span>{{end}}</td>
</tr>
{{end}}
</table>
{{if .ConfigServers.Outgoing}}
<h3 style="margin-top:.7rem">Outgoing</h3>
<table>
<tr><th>Type</th><th>Hostname</th><th>Port</th><th>Socket</th><th>Auth</th></tr>
{{range .ConfigServers.Outgoing}}
<tr>
<td>{{.Type}}</td>
<td><code>{{.Hostname}}</code></td>
<td>{{.Port}}</td>
<td>{{if .Encrypted}}<span class="chk-ok">{{.SocketType}}</span>{{else}}<span class="chk-fail">{{.SocketType}}</span>{{end}}</td>
<td>{{if .AuthSafe}}{{.Authentication}}{{else}}<span class="chk-fail">{{.Authentication}}</span>{{end}}</td>
</tr>
{{end}}
</table>
{{end}}
{{if or .ConfigServers.CardDAV .ConfigServers.CalDAV}}
<h3 style="margin-top:.7rem">Personal data (xDAV)</h3>
<ul>
{{range .ConfigServers.CardDAV}}<li>CardDAV: <code>{{.ServerURL}}</code></li>{{end}}
{{range .ConfigServers.CalDAV}}<li>CalDAV: <code>{{.ServerURL}}</code></li>{{end}}
</ul>
{{end}}
</div>
{{end}}
<div class="section">
<h2>RFC 6186 SRV records</h2>
{{if .SRVRecords}}
<table>
<tr><th>Service</th><th>Target</th><th>Port</th><th>Prio</th><th>Weight</th></tr>
{{range .SRVRecords}}
<tr{{if .Skip}} class="row-strike"{{end}}>
<td><code>{{.Service}}</code></td>
<td>{{if .Skip}}<em>disabled (.)</em>{{else}}<code>{{.Target}}</code>{{end}}</td>
<td>{{.Port}}</td>
<td>{{.Priority}}</td>
<td>{{.Weight}}</td>
</tr>
{{end}}
</table>
{{else}}
<p class="mini">No SRV records found.</p>
{{end}}
</div>
<div class="section">
<h2>Microsoft Autodiscover ({{len .Autodiscover}})</h2>
{{if .Autodiscover}}
{{range .Autodiscover}}
<details{{if eq (string .Verdict) "fail"}} open{{end}}>
<summary>
<span class="probe-source">{{.Source}}</span>
<span class="probe-url"><code>{{.URL}}</code></span>
<span class="badge {{badgeClass .Verdict}}">{{.VerdictText}}</span>
</summary>
<div class="details-body">
<dl class="kv">
{{if .StatusCode}}<dt>HTTP</dt><dd>{{.StatusCode}}</dd>{{end}}
{{if .DurationMs}}<dt>Duration</dt><dd>{{.DurationMs}} ms</dd>{{end}}
{{if .TLSSubject}}<dt>TLS subject</dt><dd>{{.TLSSubject}}</dd>{{end}}
{{if .Error}}<dt>Error</dt><dd style="color:#dc2626">{{.Error}}</dd>{{end}}
{{if .ParseError}}<dt>Parse error</dt><dd style="color:#dc2626">{{.ParseError}}</dd>{{end}}
</dl>
</div>
</details>
{{end}}
{{else}}
<p class="mini">Autodiscover probes were disabled.</p>
{{end}}
</div>
<div class="section">
<h2>Example <code>config-v1.1.xml</code></h2>
<p class="mini">Paste-ready starting point. Adjust hostnames and ports before publishing.</p>
<pre>{{.ExampleXML}}</pre>
</div>
</body>
</html>`))

518
checker/rule.go Normal file
View file

@ -0,0 +1,518 @@
package checker
import (
"context"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func getData(ctx context.Context, obs sdk.ObservationGetter) (*Data, *sdk.CheckState) {
var d Data
if err := obs.Get(ctx, ObservationKeyAutoconfig, &d); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to get autoconfig data: %v", err),
Code: "autoconfig_error",
}
}
return &d, nil
}
func single(s sdk.CheckState) []sdk.CheckState { return []sdk.CheckState{s} }
// ── Rule: at least one discovery method works ───────────────────────────────
type presenceRule struct{}
func PresenceRule() sdk.CheckRule { return &presenceRule{} }
func (r *presenceRule) Name() string {
return "autoconfig_presence"
}
func (r *presenceRule) Description() string {
return "Checks that at least one email-autoconfiguration discovery method answers for the domain."
}
func (r *presenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
d, errState := getData(ctx, obs)
if errState != nil {
return single(*errState)
}
for _, p := range d.Autoconfig {
if p.Parsed != nil {
return single(sdk.CheckState{
Status: sdk.StatusOK,
Message: fmt.Sprintf("Autoconfig served via %s (%s)", p.Source, p.Result.URL),
Code: "autoconfig_found",
})
}
}
// Autodiscover is acceptable as a fallback.
for _, p := range d.Autodiscover {
if p.Parsed != nil {
return single(sdk.CheckState{
Status: sdk.StatusWarn,
Message: fmt.Sprintf("Only Microsoft Autodiscover responds (%s); publishing a Thunderbird clientConfig is recommended for broader client support.", p.Result.URL),
Code: "autoconfig_only_autodiscover",
})
}
}
// Just SRV records? Flag but do not call it a full failure.
if hasUsableSRV(d.SRV) {
return single(sdk.CheckState{
Status: sdk.StatusWarn,
Message: "Only RFC 6186 SRV records are published; modern clients still need a clientConfig XML to learn the authentication method to use.",
Code: "autoconfig_only_srv",
})
}
return single(sdk.CheckState{
Status: sdk.StatusCrit,
Message: "No email autoconfiguration discovered: autoconfig, .well-known, Autodiscover and SRV all failed.",
Code: "autoconfig_missing",
})
}
// ── Rule: the preferred endpoint (autoconfig.<domain>) is reachable ─────────
type preferredEndpointRule struct{}
func PreferredEndpointRule() sdk.CheckRule { return &preferredEndpointRule{} }
func (r *preferredEndpointRule) Name() string {
return "autoconfig_preferred_endpoint"
}
func (r *preferredEndpointRule) Description() string {
return "Checks that https://autoconfig.<domain>/mail/config-v1.1.xml (the primary endpoint recommended by the draft) is reachable and serves a valid clientConfig."
}
func (r *preferredEndpointRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
d, errState := getData(ctx, obs)
if errState != nil {
return single(*errState)
}
var autoconfigProbe, wellKnownProbe *AutoconfigProbe
anyOK := false
for i := range d.Autoconfig {
p := &d.Autoconfig[i]
if p.Parsed != nil {
anyOK = true
}
switch p.Source {
case "autoconfig":
autoconfigProbe = p
case "wellknown":
wellKnownProbe = p
}
}
// When nothing works, let the presence rule drive the verdict.
if !anyOK {
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Message: "No autoconfig responded; primary endpoint not evaluated.",
Code: "autoconfig_preferred_skip",
})
}
if autoconfigProbe != nil && autoconfigProbe.Parsed != nil {
return single(sdk.CheckState{
Status: sdk.StatusOK,
Message: "Primary endpoint autoconfig." + d.Domain + " is live and serves a valid clientConfig.",
Code: "autoconfig_preferred_ok",
})
}
if wellKnownProbe != nil && wellKnownProbe.Parsed != nil {
return single(sdk.CheckState{
Status: sdk.StatusWarn,
Message: "Primary endpoint autoconfig." + d.Domain + " is missing; only the .well-known fallback is reachable. Thunderbird tries autoconfig.<domain> first, so publish it to avoid the extra attempt.",
Code: "autoconfig_preferred_missing",
})
}
// The rest (ISPDB / MX-based) is a weaker signal.
return single(sdk.CheckState{
Status: sdk.StatusWarn,
Message: "Autoconfig is only served by a fallback (ISPDB or MX host). Publishing https://autoconfig." + d.Domain + "/mail/config-v1.1.xml gives clients a deterministic match for your domain.",
Code: "autoconfig_preferred_fallback",
})
}
// ── Rule: TLS health of the autoconfig endpoints ────────────────────────────
type tlsRule struct{}
func TLSRule() sdk.CheckRule { return &tlsRule{} }
func (r *tlsRule) Name() string {
return "autoconfig_tls"
}
func (r *tlsRule) Description() string {
return "Checks that autoconfig endpoints are served over HTTPS with a valid TLS certificate."
}
func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
d, errState := getData(ctx, obs)
if errState != nil {
return single(*errState)
}
var out []sdk.CheckState
for _, p := range d.Autoconfig {
if p.Source == "ispdb" || p.Source == "mx-ispdb" {
continue
}
// Skip probes that did not actually connect (nothing to say about TLS).
if p.Result.StatusCode == 0 && p.Result.TLSError == "" {
continue
}
subject := p.Source
switch {
case p.Result.TLSError != "":
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Subject: subject,
Message: "TLS failure: " + p.Result.TLSError,
Code: "autoconfig_tls_invalid",
})
case strings.HasPrefix(p.Result.URL, "http://") && p.Result.StatusCode >= 200 && p.Result.StatusCode < 300:
out = append(out, sdk.CheckState{
Status: sdk.StatusWarn,
Subject: subject,
Message: "Served over plain HTTP (" + p.Result.URL + "). Thunderbird accepts this only when HTTPS has already failed; serve the file over HTTPS.",
Code: "autoconfig_tls_plaintext",
})
case p.Parsed != nil:
out = append(out, sdk.CheckState{
Status: sdk.StatusOK,
Subject: subject,
Message: "HTTPS handshake succeeded and clientConfig was parsed.",
Code: "autoconfig_tls_ok",
})
}
}
if len(out) == 0 {
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Message: "No autoconfig probe reached an endpoint; TLS not assessed.",
Code: "autoconfig_tls_skip",
})
}
return out
}
// ── Rule: advertised servers actually encrypt mail ──────────────────────────
type encryptionRule struct{}
func EncryptionRule() sdk.CheckRule { return &encryptionRule{} }
func (r *encryptionRule) Name() string {
return "autoconfig_server_encryption"
}
func (r *encryptionRule) Description() string {
return "Checks that servers advertised by autoconfig use SSL or STARTTLS and a non-cleartext auth method where appropriate."
}
func (r *encryptionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
d, errState := getData(ctx, obs)
if errState != nil {
return single(*errState)
}
if d.ClientConfig == nil {
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Message: "No clientConfig parsed; encryption check skipped.",
Code: "autoconfig_encryption_skip",
})
}
servers := make([]ServerConfig, 0, len(d.ClientConfig.Incoming)+len(d.ClientConfig.Outgoing))
servers = append(servers, d.ClientConfig.Incoming...)
servers = append(servers, d.ClientConfig.Outgoing...)
var out []sdk.CheckState
for _, s := range servers {
subject := fmt.Sprintf("%s %s:%d", s.Type, s.Hostname, s.Port)
switch {
case !isEncryptedSocket(s.SocketType):
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Subject: subject,
Message: fmt.Sprintf("Advertised as plaintext (socketType=%q). Serve with SSL or STARTTLS.", s.SocketType),
Code: "autoconfig_plaintext_server",
})
case strings.EqualFold(s.Authentication, "password-cleartext"):
// Cleartext password is fine here because the transport is encrypted.
out = append(out, sdk.CheckState{
Status: sdk.StatusOK,
Subject: subject,
Message: "Encrypted transport (" + s.SocketType + ") carries cleartext-password auth; acceptable.",
Code: "autoconfig_encryption_ok",
})
default:
out = append(out, sdk.CheckState{
Status: sdk.StatusOK,
Subject: subject,
Message: "Encrypted via " + s.SocketType + ", auth=" + s.Authentication + ".",
Code: "autoconfig_encryption_ok",
})
}
}
if len(out) == 0 {
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Message: "clientConfig declares no server to evaluate.",
Code: "autoconfig_encryption_skip",
})
}
return out
}
// ── Rule: cross-source consistency ──────────────────────────────────────────
type consistencyRule struct{}
func ConsistencyRule() sdk.CheckRule { return &consistencyRule{} }
func (r *consistencyRule) Name() string {
return "autoconfig_consistency"
}
func (r *consistencyRule) Description() string {
return "Cross-checks hostnames and ports reported by autoconfig, Autodiscover and SRV records."
}
func (r *consistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
d, errState := getData(ctx, obs)
if errState != nil {
return single(*errState)
}
if d.ClientConfig == nil {
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Message: "No clientConfig to compare.",
Code: "autoconfig_consistency_skip",
})
}
var out []sdk.CheckState
if !clientConfigCoversDomain(d.ClientConfig, d.Domain) {
out = append(out, sdk.CheckState{
Status: sdk.StatusWarn,
Subject: "domain-claim",
Message: fmt.Sprintf("clientConfig does not claim domain %q (emailProvider id=%q, domains=%v)", d.Domain, d.ClientConfig.EmailProviderID, d.ClientConfig.Domains),
Code: "autoconfig_inconsistent",
})
}
for _, m := range srvVersusServers(d.SRV, d.ClientConfig.Incoming, d.ClientConfig.Outgoing) {
out = append(out, sdk.CheckState{
Status: sdk.StatusWarn,
Subject: m.service,
Message: m.message,
Code: "autoconfig_inconsistent",
})
}
if len(out) == 0 {
return single(sdk.CheckState{
Status: sdk.StatusOK,
Message: "Autoconfig data is self-consistent (domain claim and SRV match).",
Code: "autoconfig_consistent",
})
}
return out
}
// ── Rule: RFC 6186 SRV presence ─────────────────────────────────────────────
type srvRule struct{}
func SRVRule() sdk.CheckRule { return &srvRule{} }
func (r *srvRule) Name() string {
return "autoconfig_srv_records"
}
func (r *srvRule) Description() string {
return "Checks that RFC 6186 SRV records (_imaps._tcp, _submissions._tcp, …) complement the autoconfig XML."
}
func (r *srvRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
d, errState := getData(ctx, obs)
if errState != nil {
return single(*errState)
}
var incoming, submission bool
for _, rec := range d.SRV {
if rec.Skip {
continue
}
inc, sub := classifySRV(rec.Service)
incoming = incoming || inc
submission = submission || sub
}
switch {
case incoming && submission:
return single(sdk.CheckState{
Status: sdk.StatusOK,
Message: "RFC 6186 SRV records cover incoming and submission.",
Code: "autoconfig_srv_complete",
})
case incoming || submission:
missing := "submission"
if !incoming {
missing = "incoming"
}
return single(sdk.CheckState{
Status: sdk.StatusWarn,
Message: "RFC 6186 SRV records miss " + missing + "; clients without autoconfig XML cannot fully bootstrap.",
Code: "autoconfig_srv_partial",
})
default:
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Message: "No RFC 6186 SRV records published. Not mandatory, but a cheap safety net.",
Code: "autoconfig_srv_missing",
})
}
}
// ── Rule: Autodiscover behaviour ────────────────────────────────────────────
type autodiscoverRule struct{}
func AutodiscoverRule() sdk.CheckRule { return &autodiscoverRule{} }
func (r *autodiscoverRule) Name() string {
return "autoconfig_autodiscover"
}
func (r *autodiscoverRule) Description() string {
return "Reports whether Microsoft Autodiscover (POX) responds on the domain."
}
func (r *autodiscoverRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
d, errState := getData(ctx, obs)
if errState != nil {
return single(*errState)
}
if d.AutodiscoverResult != nil {
if d.AutodiscoverResult.RedirectAddr != "" || d.AutodiscoverResult.RedirectURL != "" {
return single(sdk.CheckState{
Status: sdk.StatusOK,
Message: "Autodiscover answers with a redirect.",
Code: "autoconfig_autodiscover_redirect",
})
}
return single(sdk.CheckState{
Status: sdk.StatusOK,
Message: fmt.Sprintf("Autodiscover returns %d protocol definition(s).", len(d.AutodiscoverResult.Protocols)),
Code: "autoconfig_autodiscover_ok",
})
}
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Message: "No Microsoft Autodiscover endpoint found (not required for Thunderbird-style clients).",
Code: "autoconfig_autodiscover_missing",
})
}
// ── helpers ─────────────────────────────────────────────────────────────────
func hasUsableSRV(rs []SRVRecord) bool {
for _, r := range rs {
if !r.Skip && r.Target != "" {
return true
}
}
return false
}
func classifySRV(service string) (incoming, submission bool) {
switch service {
case "_imaps._tcp", "_imap._tcp", "_pop3s._tcp", "_pop3._tcp":
return true, false
case "_submissions._tcp", "_submission._tcp":
return false, true
}
return false, false
}
func isEncryptedSocket(s string) bool {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "SSL", "STARTTLS":
return true
}
return false
}
func clientConfigCoversDomain(cfg *ClientConfig, domain string) bool {
if cfg == nil {
return false
}
domain = strings.ToLower(strings.TrimSuffix(domain, "."))
if strings.EqualFold(cfg.EmailProviderID, domain) {
return true
}
for _, d := range cfg.Domains {
if strings.EqualFold(strings.TrimSuffix(d, "."), domain) {
return true
}
}
return false
}
type srvMismatch struct {
service string
message string
}
// srvVersusServers returns one entry per mismatching SRV service.
func srvVersusServers(srv []SRVRecord, incoming, outgoing []ServerConfig) []srvMismatch {
byType := map[string][]SRVRecord{}
for _, r := range srv {
if r.Skip {
continue
}
byType[r.Service] = append(byType[r.Service], r)
}
services := make([]string, 0, len(byType))
for svc := range byType {
services = append(services, svc)
}
sort.Strings(services)
var out []srvMismatch
for _, svc := range services {
recs := byType[svc]
var configs []ServerConfig
switch svc {
case "_imaps._tcp", "_imap._tcp":
configs = filterType(incoming, "imap")
case "_pop3s._tcp", "_pop3._tcp":
configs = filterType(incoming, "pop3")
case "_submissions._tcp", "_submission._tcp":
configs = filterType(outgoing, "smtp")
}
if len(configs) == 0 {
continue
}
match := false
for _, rec := range recs {
for _, c := range configs {
if strings.EqualFold(strings.TrimSuffix(rec.Target, "."), c.Hostname) && (rec.Port == 0 || int(rec.Port) == c.Port) {
match = true
break
}
}
}
if !match {
out = append(out, srvMismatch{
service: svc,
message: fmt.Sprintf("No SRV %s record matches any clientConfig %s server (host/port)", svc, configs[0].Type),
})
}
}
return out
}
func filterType(in []ServerConfig, t string) []ServerConfig {
var out []ServerConfig
for _, s := range in {
if strings.EqualFold(s.Type, t) {
out = append(out, s)
}
}
return out
}

148
checker/types.go Normal file
View file

@ -0,0 +1,148 @@
// Package checker probes a domain for the three competing email
// autoconfiguration mechanisms (Bucksch autoconfig, Microsoft Autodiscover
// POX, RFC 6186 SRV) and cross-checks them.
package checker
import (
"time"
)
// ObservationKeyAutoconfig is the observation key for autoconfig data.
const ObservationKeyAutoconfig = "email_autoconfig"
// Data is the full collected payload.
type Data struct {
Domain string `json:"domain"`
Email string `json:"email"`
CollectedAt time.Time `json:"collected_at"`
MX []MXRecord `json:"mx,omitempty"`
MXError string `json:"mx_error,omitempty"`
SRV []SRVRecord `json:"srv,omitempty"`
Autoconfig []AutoconfigProbe `json:"autoconfig,omitempty"`
Autodiscover []AutodiscoverProbe `json:"autodiscover,omitempty"`
// First successful autoconfig parse, promoted here for rules to consume.
ClientConfig *ClientConfig `json:"client_config,omitempty"`
ClientConfigSource string `json:"client_config_source,omitempty"`
AutodiscoverResult *AutodiscoverResponse `json:"autodiscover_result,omitempty"`
}
// MXRecord is a single MX record.
type MXRecord struct {
Host string `json:"host"`
Preference uint16 `json:"preference"`
}
// SRVRecord is a single RFC 6186 SRV record observation.
type SRVRecord struct {
Service string `json:"service"` // RFC 6186 tag, e.g. "_imaps._tcp"
Target string `json:"target"`
Port uint16 `json:"port"`
Priority uint16 `json:"priority"`
Weight uint16 `json:"weight"`
// Skip means the service is explicitly disabled (RFC 6186 target ".").
Skip bool `json:"skip,omitempty"`
}
// ProbeResult captures the outcome of a single HTTP probe.
type ProbeResult struct {
URL string `json:"url"`
Method string `json:"method,omitempty"`
StatusCode int `json:"status_code,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
ContentType string `json:"content_type,omitempty"`
BodyBytes int `json:"body_bytes,omitempty"`
Redirected bool `json:"redirected,omitempty"`
FinalURL string `json:"final_url,omitempty"`
TLSServerName string `json:"tls_server_name,omitempty"`
TLSIssuer string `json:"tls_issuer,omitempty"`
TLSSubject string `json:"tls_subject,omitempty"`
TLSNotAfter string `json:"tls_not_after,omitempty"`
TLSError string `json:"tls_error,omitempty"`
Error string `json:"error,omitempty"`
ParseError string `json:"parse_error,omitempty"`
}
// AutoconfigProbe is one probe attempt for Thunderbird-style autoconfig.
type AutoconfigProbe struct {
Source string `json:"source"` // "autoconfig", "wellknown", "http-autoconfig", "ispdb", "mx-autoconfig", "mx-ispdb"
Result ProbeResult `json:"result"`
Parsed *ClientConfig `json:"parsed,omitempty"`
}
// AutodiscoverProbe is one probe attempt for MS Autodiscover (POX).
type AutodiscoverProbe struct {
Source string `json:"source"` // "root", "subdomain", "srv", "redirect"
Result ProbeResult `json:"result"`
Parsed *AutodiscoverResponse `json:"parsed,omitempty"`
}
// ClientConfig is the parsed Thunderbird-style clientConfig document.
type ClientConfig struct {
Version string `json:"version,omitempty"`
EmailProviderID string `json:"email_provider_id,omitempty"`
DisplayName string `json:"display_name,omitempty"`
ShortName string `json:"short_name,omitempty"`
Domains []string `json:"domains,omitempty"`
Incoming []ServerConfig `json:"incoming,omitempty"`
Outgoing []ServerConfig `json:"outgoing,omitempty"`
AddressBook []DavServer `json:"address_book,omitempty"`
Calendar []DavServer `json:"calendar,omitempty"`
WebMail *WebMail `json:"webmail,omitempty"`
Documentation []Documentation `json:"documentation,omitempty"`
}
// ServerConfig is one incoming/outgoing server definition.
type ServerConfig struct {
Type string `json:"type"` // imap, pop3, smtp
Hostname string `json:"hostname"`
Port int `json:"port"`
SocketType string `json:"socket_type"` // plain, SSL, STARTTLS
Username string `json:"username"` // may contain %EMAIL% placeholders
Authentication string `json:"authentication"` // password-cleartext, password-encrypted, OAuth2, ...
}
// DavServer is a CardDAV or CalDAV server reference.
type DavServer struct {
Type string `json:"type"`
Username string `json:"username,omitempty"`
Authentication string `json:"authentication,omitempty"`
ServerURL string `json:"server_url,omitempty"`
}
// WebMail holds webmail configuration (if any).
type WebMail struct {
LoginPage string `json:"login_page,omitempty"`
}
// Documentation is a clientConfig <documentation> entry.
type Documentation struct {
URL string `json:"url,omitempty"`
Descr string `json:"descr,omitempty"`
}
// AutodiscoverResponse is the parsed POX response.
type AutodiscoverResponse struct {
DisplayName string `json:"display_name,omitempty"`
// Set when Action is redirectAddr / redirectUrl; mutually exclusive with Protocols.
RedirectAddr string `json:"redirect_addr,omitempty"`
RedirectURL string `json:"redirect_url,omitempty"`
Protocols []AutodiscoverProtocol `json:"protocols,omitempty"`
}
// AutodiscoverProtocol covers IMAP/POP/SMTP fields; Exchange-only protocols
// (EXCH/EXPR/MobileSync) are stored but not analysed.
type AutodiscoverProtocol struct {
Type string `json:"type"` // IMAP, POP3, SMTP, EXCH, EXPR, WEB, MobileSync
Server string `json:"server,omitempty"`
Port int `json:"port,omitempty"`
Encryption string `json:"encryption,omitempty"` // SSL, TLS, None
SSL string `json:"ssl,omitempty"` // on/off
LoginName string `json:"login_name,omitempty"`
DomainRequired string `json:"domain_required,omitempty"`
AuthRequired string `json:"auth_required,omitempty"`
}