Documentation
¶
Overview ¶
Package mtasts discovers, caches, and enforces MTA-STS (RFC 8461) policies for outbound SMTP delivery.
MTA-STS (SMTP MTA Strict Transport Security) lets a recipient domain publish a policy declaring that senders MUST reach its mail servers over authenticated TLS. A sending MTA discovers the policy by first reading the recipient's _mta-sts.<domain> TXT record — which carries a short policy id — and then, when that id is new, fetching the policy file over HTTPS from https://mta-sts.<domain>/.well-known/mta-sts.txt. The policy names the MX hosts allowed to receive mail and a mode: "enforce", "testing", or "none".
When the mode is "enforce" the sender MUST negotiate STARTTLS to an MX host that is (a) named by the policy and (b) presents a certificate valid for that host; otherwise the message is deferred rather than delivered in the clear. Discovery fails open (RFC 8461 section 5) only when no policy is cached: a missing or invalid TXT record, a fetch error, or an unparseable policy fall back to ordinary opportunistic TLS, so a broken policy never blocks mail. A valid, non-expired cached policy is never dropped by a transient discovery failure, though — it stays in effect until its max_age elapses (RFC 8461 sections 3.1 and 3.3), which defeats the section 10.2 downgrade attack of blocking the TXT response or the policy fetch.
Discovery and caching ¶
A Resolver performs discovery. Resolver.Resolve reads the TXT record, serves a cached policy while its max_age has not elapsed and the id is unchanged, and otherwise fetches and parses the HTTPS policy file. If that live discovery transiently fails while a non-expired policy is still cached, the cached policy is served rather than dropped. It returns the parsed Policy or ErrNoPolicy. ParsePolicy parses a policy file body on its own if the fetch is handled elsewhere.
Enforcement ¶
Evaluate applies a discovered policy to the observed TLS outcome of one delivery attempt, returning a deferrable EnforceError when an "enforce" policy is violated. Policy.MatchesMX reports whether a concrete MX hostname is named by the policy, using RFC 6125 matching: an exact host, or a single leading "*." wildcard label.
Testing without a network ¶
A Resolver's DNS and HTTPS steps are injectable through its LookupTXT, FetchPolicy, and Now fields, so discovery can be driven from in-memory data in tests, or pointed at an insecure fetch for a development deployment. See the package example.
Example ¶
Example discovers a domain's MTA-STS policy and then judges one delivery attempt against it. It injects in-memory DNS and HTTPS so the round trip is self-contained. In production, use mtasts.NewResolver() as-is: it reads real _mta-sts.<domain> TXT records and fetches the policy over verified HTTPS.
package main
import (
"context"
"fmt"
"github.com/rest-mail/go-mtasts"
)
func main() {
r := mtasts.NewResolver()
// Serve the _mta-sts.example.com TXT record from memory. It carries the
// policy id that tells the resolver whether its cache is still fresh.
r.LookupTXT = func(_ context.Context, _ string) ([]string, error) {
return []string{"v=STSv1; id=20260101T000000Z"}, nil
}
// Serve https://mta-sts.example.com/.well-known/mta-sts.txt from memory.
r.FetchPolicy = func(_ context.Context, _ string) ([]byte, error) {
return []byte("version: STSv1\n" +
"mode: enforce\n" +
"mx: mail.example.com\n" +
"mx: *.mx.example.com\n" +
"max_age: 604800\n"), nil
}
policy, err := r.Resolve(context.Background(), "example.com")
if err != nil {
// ErrNoPolicy (or any error) means fall back to opportunistic TLS.
panic(err)
}
// After connecting to an MX host and negotiating STARTTLS, judge the
// attempt. Evaluate returns nil when delivery may proceed and a deferrable
// *EnforceError when an "enforce" policy is violated.
err = mtasts.Evaluate(mtasts.EvalInput{
Policy: policy,
Domain: "example.com",
MXHost: "mail.example.com",
STARTTLS: true,
CertValid: true,
})
fmt.Printf("mode=%s mx-covered=%v deliver-err=%v\n",
policy.Mode, policy.MatchesMX("mail.example.com"), err)
}
Output: mode=enforce mx-covered=true deliver-err=<nil>
Index ¶
- Constants
- Variables
- func Evaluate(in EvalInput) error
- func HTTPFetch(ctx context.Context, url string, insecure bool) ([]byte, error)
- func PolicyURL(domain string) string
- func TXTName(domain string) string
- type EnforceError
- type EvalInput
- type FetchPolicyFunc
- type LookupTXTFunc
- type Policy
- type Resolver
Examples ¶
Constants ¶
const ( ModeEnforce = "enforce" ModeTesting = "testing" ModeNone = "none" )
Policy modes (RFC 8461 section 5).
const Version = "STSv1"
Version is the only MTA-STS policy version defined by RFC 8461.
Variables ¶
var ErrNoPolicy = errors.New("mtasts: no policy")
ErrNoPolicy is returned by Resolver.Resolve when the domain publishes no usable MTA-STS policy. It signals the caller to fall back to ordinary opportunistic-TLS behaviour (fail-open, per RFC 8461 section 5).
Functions ¶
func Evaluate ¶
Evaluate applies MTA-STS policy to a delivery attempt.
The enforcement mode is taken from the discovered policy (Policy.Mode), never from a caller-supplied override: enforcement is a property of the recipient's published policy (RFC 8461 §5).
It returns nil when delivery may proceed, and an *EnforceError (deferrable) when an "enforce" policy is violated. For "testing", "none", or no policy it always returns nil — those modes never block delivery (a "testing" would-fail is a reporting signal only, logged by the caller).
Under "enforce" three conditions must all hold: the MX host must be named by the policy, STARTTLS must have succeeded, and the certificate must be valid for the MX host. Setting AllowInsecureDowngrade suppresses the block (a deliberate dev/test opt-in); its zero value fails closed.
func HTTPFetch ¶
HTTPFetch performs the HTTPS GET for a policy file. Per RFC 8461 section 3.3 the policy MUST be fetched over HTTPS with a verified server certificate, and redirects MUST NOT be followed:
- The URL scheme is pinned to https. A non-https scheme is rejected so a direct caller cannot fetch a policy over cleartext; only when insecure is explicitly set (a dev/test deployment) is a plaintext http URL permitted.
- The server certificate for mta-sts.<domain> is verified unless insecure is set.
- 3xx redirects are not followed, which also prevents a redirect from downgrading the fetch to http or steering it to a different host — either of which would defeat the policy origin.
A plaintext, invalid-certificate, or redirected fetch therefore fails rather than returning a policy.
Types ¶
type EnforceError ¶
EnforceError reports that an MTA-STS "enforce" requirement was not met for a delivery attempt. It is a deferrable (transient) condition: the outbound queue should retry later rather than bounce the message, because a valid TLS path to the recipient may become available (cert renewal, MX repair, etc.).
func (*EnforceError) Error ¶
func (e *EnforceError) Error() string
type EvalInput ¶
type EvalInput struct {
// Policy is the discovered policy (nil means no policy was published). The
// effective enforcement mode is Policy.Mode — the value published by the
// recipient — not a caller-supplied override (RFC 8461 §5).
Policy *Policy
// Domain is the recipient domain (for diagnostics).
Domain string
// MXHost is the MX host the attempt connected to.
MXHost string
// STARTTLS is true when STARTTLS was negotiated successfully.
STARTTLS bool
// CertValid is true when the presented certificate chained to a trusted
// root and was valid for MXHost.
CertValid bool
// AllowInsecureDowngrade, when true, downgrades an "enforce" policy to
// report-only so a would-fail no longer blocks delivery. This is a
// deliberate, dangerous opt-in for dev/test deployments where certificate
// verification is globally disabled; it MUST NOT be set in production. When
// false (the zero value), an "enforce" policy fails closed as RFC 8461 §5
// requires — the safe default.
AllowInsecureDowngrade bool
}
EvalInput captures the observed TLS outcome of a single SMTP delivery attempt to one MX host, to be judged against a discovered policy.
type FetchPolicyFunc ¶
FetchPolicyFunc fetches the raw policy file at the given HTTPS URL. Injectable for testing.
type LookupTXTFunc ¶
LookupTXTFunc resolves TXT records for a name. Injectable for testing.
type Policy ¶
type Policy struct {
Version string // always "STSv1"
Mode string // "enforce", "testing", or "none"
MX []string // MX host patterns; may use a single leading-label wildcard, e.g. "*.example.com"
MaxAge int // policy lifetime in seconds
}
Policy is a parsed MTA-STS policy file.
func ParsePolicy ¶
ParsePolicy parses the body of an MTA-STS policy file (RFC 8461 section 3.2).
It is deliberately lenient about unknown keys (per the spec) but strict about the fields required to make an enforcement decision: version must be STSv1, mode must be one of the defined values, max_age must be an integer in the range 1..31557600 (RFC 8461 §3.2), and at least one mx pattern must be present unless the mode is "none".
Example ¶
ExampleParsePolicy parses a policy file body directly (without discovery) and checks whether a given MX host is covered by it.
package main
import (
"fmt"
"github.com/rest-mail/go-mtasts"
)
func main() {
body := []byte("version: STSv1\n" +
"mode: enforce\n" +
"mx: mail.example.com\n" +
"mx: *.mx.example.com\n" +
"max_age: 604800\n")
policy, err := mtasts.ParsePolicy(body)
if err != nil {
panic(err)
}
fmt.Println(policy.MatchesMX("mail.example.com")) // exact host
fmt.Println(policy.MatchesMX("relay.mx.example.com")) // one wildcard label
fmt.Println(policy.MatchesMX("mx.example.com")) // wildcard needs a label
}
Output: true true false
func (*Policy) MatchesCert ¶
func (p *Policy) MatchesCert(cert *x509.Certificate) bool
MatchesCert reports whether any identity presented by cert is named by the policy. It handles wildcards on either side: a policy wildcard covering a concrete cert name, or a wildcard cert name covering a concrete policy entry.
MatchesCert compares names only; it does not itself verify that the certificate chains to a trusted root. A typical send path lets the STARTTLS handshake verify the presented certificate against the MX hostname (which MatchesMX has already confirmed the policy names), so this method is most useful for offline policy analysis and tests rather than gating a live socket.
func (*Policy) MatchesMX ¶
MatchesMX reports whether a concrete MX hostname is named by the policy.
Patterns are matched per RFC 8461 section 4.1 / RFC 6125: an exact (case-insensitive) match, or a single leading wildcard label ("*.example.com") that matches exactly one DNS label ("mail.example.com" but not "example.com" nor "a.b.example.com").
type Resolver ¶
type Resolver struct {
// LookupTXT resolves the _mta-sts.<domain> TXT record. Defaults to
// net.DefaultResolver.LookupTXT.
LookupTXT LookupTXTFunc
// FetchPolicy fetches the policy file over HTTPS. Defaults to a verified
// HTTPS GET that does not follow redirects.
FetchPolicy FetchPolicyFunc
// Now returns the current time; overridable in tests. Defaults to time.Now.
Now func() time.Time
// NegativeTTL is how long a failed policy fetch/parse for a given version id
// is remembered so repeated Resolve calls within the window do not re-probe a
// broken or blocked policy host (RFC 8461 §3.3). Zero uses defaultNegativeTTL
// (5 minutes); a value below that floor is raised to it, since a shorter
// interval would reopen the re-probe amplifier the negative cache exists to
// close.
NegativeTTL time.Duration
// contains filtered or unexported fields
}
Resolver discovers and caches MTA-STS policies. The DNS and HTTP fetch steps are injectable so the resolver can be unit-tested without network access.
func NewResolver ¶
func NewResolver() *Resolver
NewResolver returns a Resolver wired to the real network. Callers may replace LookupTXT / FetchPolicy / Now afterwards (e.g. to plumb an insecure fetch for a dev deployment, or to inject fakes in tests).
func (*Resolver) Resolve ¶
Resolve returns the MTA-STS policy for domain, or ErrNoPolicy if none is usable. It reads the _mta-sts.<domain> TXT record for the policy id, serves a cached policy while its max_age has not elapsed and the id is unchanged, and otherwise fetches and parses the HTTPS policy file.
A valid, non-expired cached policy is never dropped by a transient discovery failure. When the TXT lookup errors, carries no usable id, or the HTTPS re-fetch fails or returns an unparseable body, Resolve serves the cached policy (regardless of its id) until its max_age legitimately elapses. This upholds RFC 8461 sections 3.1 and 3.3 — "the absence of a usable TXT record is not by itself sufficient to remove a sender's previously cached policy", and a valid cached policy MUST be applied when no live policy can be discovered — and defeats the section 10.2 downgrade attack in which an on-path adversary strips MTA-STS by merely blocking the TXT response or the policy fetch.
Fail-open (RFC 8461 section 5) only when no non-expired policy is cached: a missing/invalid TXT record, a fetch error, or an unparseable policy then yield ErrNoPolicy so the caller reverts to opportunistic TLS rather than blocking mail.
A fetch or parse failure for a discovered id is negative-cached for NegativeTTL (default 5 minutes). While that entry is live, a later Resolve that discovers the same id skips the HTTPS fetch and falls straight through to the cached-or-fail-open path, so a domain whose policy host is down or being blocked is not re-probed on every outbound message (RFC 8461 §3.3). The suppression is scoped to the id: a newly published id is fetched immediately.