// Package mtasts implements RFC 8461 MTA-STS policy discovery for outbound // SMTP delivery — a domain publishes a DNS TXT record plus an HTTPS-hosted // policy document declaring which MX hosts must be used and whether TLS is // mandatory. Unlike DANE, this doesn't depend on DNSSEC: the policy fetch's // own TLS certificate (normal CA/PKI, already handled by net/http) is the // trust anchor, per the RFC. package mtasts import ( "context" "fmt" "io" "net" "net/http" "strconv" "strings" "time" ) // Policy is a parsed MTA-STS policy document. type Policy struct { ID string // from the _mta-sts TXT record, not the policy body Mode string // "enforce" | "testing" | "none" MXPatterns []string MaxAge time.Duration } // Discover fetches domain's MTA-STS policy. Returns (nil, nil) — not an // error — if the domain has no _mta-sts TXT record at all, since that's the // normal "this domain doesn't use MTA-STS" case. func Discover(ctx context.Context, domain string) (*Policy, error) { // Same net.DefaultResolver.LookupTXT convention already used by // internal/pipeline's SPF/DMARC checks — no AD-flag need here, so no // reason to use the hand-rolled dnsutil client for this lookup. txts, err := net.DefaultResolver.LookupTXT(ctx, "_mta-sts."+domain) if err != nil || len(txts) == 0 { return nil, nil } var policyID string for _, txt := range txts { if !strings.HasPrefix(txt, "v=STSv1") { continue } for _, part := range strings.Split(txt, ";") { part = strings.TrimSpace(part) if id, ok := strings.CutPrefix(part, "id="); ok { policyID = id } } } if policyID == "" { return nil, nil } url := "https://mta-sts." + domain + "/.well-known/mta-sts.txt" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetching mta-sts policy for %s: %w", domain, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("mta-sts policy fetch for %s returned status %d", domain, resp.StatusCode) } // Policies are meant to be small (a handful of mx lines) — cap the read // against a hostile or misbehaving server rather than trusting Content-Length. body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) if err != nil { return nil, fmt.Errorf("reading mta-sts policy body: %w", err) } policy, err := parsePolicy(string(body)) if err != nil { return nil, fmt.Errorf("parsing mta-sts policy for %s: %w", domain, err) } policy.ID = policyID return policy, nil } func parsePolicy(body string) (*Policy, error) { p := &Policy{} var maxAgeSeconds int for _, line := range strings.Split(body, "\n") { line = strings.TrimSpace(line) if line == "" { continue } key, value, ok := strings.Cut(line, ":") if !ok { continue } key = strings.TrimSpace(key) value = strings.TrimSpace(value) switch key { case "version": if value != "STSv1" { return nil, fmt.Errorf("unsupported policy version %q", value) } case "mode": p.Mode = value case "mx": p.MXPatterns = append(p.MXPatterns, value) case "max_age": if n, err := strconv.Atoi(value); err == nil { maxAgeSeconds = n } } } if p.Mode == "" { return nil, fmt.Errorf("policy missing required 'mode' field") } p.MaxAge = time.Duration(maxAgeSeconds) * time.Second return p, nil } // Matches reports whether host satisfies pattern, per RFC 8461 §4.1's // one-label wildcard rule: "*.example.com" matches "mail.example.com" but // not "example.com" itself or "a.mail.example.com". func Matches(pattern, host string) bool { pattern = strings.TrimSuffix(strings.ToLower(pattern), ".") host = strings.TrimSuffix(strings.ToLower(host), ".") suffix, isWildcard := strings.CutPrefix(pattern, "*.") if !isWildcard { return pattern == host } rest, ok := strings.CutSuffix(host, "."+suffix) return ok && rest != "" && !strings.Contains(rest, ".") }