mta_sts
🔒 SMTP MTA Strict Transport Security for Ruby.
Answers one question before delivering a message: must this connection be encrypted, and to which servers?
mta_sts resolves a recipient domain's MTA-STS policy (RFC 8461) and finds out which of its mail servers may be used and whether TLS is optional. It also discovers where the domain wants TLS failure reports sent (RFC 8460 TLSRPT).
Contents
- Installation
- How It Works
- Command Line
- What It Is Strict About
- What It Is Not That Strict
- What It Does Not Check
- Testing
- History
- Contributing
- License
Installation
Add this line to your application's Gemfile:
gem "mta_sts"
And run:
bundle install
Look up your first policy with:
require "mta_sts"
result = MtaSts.lookup("gmail.com", known: nil)
result.enforce? # => true
How It Works
Looking Up a Policy
result = MtaSts.lookup(domain, known:, resolver:, fetcher:, now:)
# => #<MtaSts::Result status:, comment:, policy:>
- domain - the recipient domain, the part after the
@ - known - the policy this library returned last time, or
nil. There's no cache here, so this is how a policy survives between calls. Required on purpose:known: nilshould be a decision, not an omission - resolver - defaults to
MtaSts::Resolver.new - fetcher - defaults to
MtaSts::Fetcher.new - now - a
Time, for expiry; defaults toTime.now
result = MtaSts.lookup("gmail.com", known: store["gmail.com"])
store["gmail.com"] = result.policy if result.policy
if result.enforce? && !result.allows?(mx_host)
# the policy names which servers are legitimate, and this is not one of them
end
Hand back what you kept, keep what comes back - both halves matter.
Results
result.status # => "found"
result.policy.mode # => :enforce
result.policy.mx # => ["gmail-smtp-in.l.google.com", "*.gmail-smtp-in.l.google.com"]
result.policy.max_age # => 86400
result.policy.id # => "20190429T010101"
result.policy.domain # => "gmail.com"
result.policy.expires_at # => 2026-07-29 14:03:11 UTC
result.enforce? # => true
result.allows?("alt1.gmail-smtp-in.l.google.com") # => true
result.allows?("mx.attacker.example") # => false
Two questions get asked at connect time: must TLS be verified and non-optional (enforce?), and is this hostname one the domain vouches for (allows?).
Status and Policy Are Separate Axes
status describes what this lookup learned. policy describes what you must honor. Nothing raises - a result always comes back with a comment:
result.status # => "temperror"
result.comment # => "DNS didn't answer for _mta-sts.example.com"
Four statuses, each with a predicate:
found/found?- a policy was fetched, or renewed against an unchangedidnone/no_record?- no_mta-stsrecord seentemperror/temperror?- DNS or the policy host didn't answerpermerror/permerror?- the record or policy file is broken
policy is present whenever there's one to honor: fresh, renewed, or the known one you passed in that hasn't expired.
| what happened | status | policy | enforce? |
|---|---|---|---|
fresh fetch, mode: enforce |
found |
present | true |
| DNS timeout, held an enforcing policy | temperror |
held | true |
| TXT record gone, held an enforcing policy | none |
held | true |
| never had one, no TXT record | none |
nil | false |
The third row looks wrong and isn't. §5.1 keeps a policy in force for its max_age regardless of what DNS says now - deleting the TXT record does not withdraw enforcement, only serving mode: none does.
Use enforce? and allows? for the connection decision, not status:
if result.enforce? && !result.allows?(mx)
defer_or_fail
elsif result.temperror? && !result.policy
defer # we don't know; don't downgrade
end
temperror is not none. A resolver timeout means "we could not ask"; reading it as "no policy published" hands an attacker a downgrade for the cost of dropping a UDP packet.
Holding On to a Policy
There's no cache in this library. lookup takes the policy you kept last time and hands one back.
result = MtaSts.lookup("example.com", known: store["example.com"])
store["example.com"] = result.policy if result.policy
Skip that round trip and §5.1 doesn't apply. Three rules govern what comes back:
- A matching
idrenews the expiry without refetching - A temporary failure keeps a policy that hasn't expired
- A fetched
mode: nonereplaces it immediately
A policy also carries the domain it was fetched for. Handing back another domain's policy under the wrong key is ignored rather than enforced. Store that too when you persist:
row = { body: policy.to_s, id: policy.id, domain: policy.domain, fetched_at: policy.fetched_at }
MtaSts::Policy.parse(row[:body], id: row[:id], domain: row[:domain], fetched_at: row[:fetched_at])
TLSRPT Record Discovery
Where a domain wants TLS failure reports sent (RFC 8460). One TXT lookup at _smtp._tls.<domain> — not report generation or submission.
result = MtaSts.reporting("gmail.com")
# => #<MtaSts::Report::Result status:, comment:, report:>
result.status # => "found"
result.rua # => ["mailto:tlsrpt@smtp.gmail.com"]
result.report.mailto
result.report.https
result.report.ignored # destinations that were named but are neither mailto: nor https:
Same status vocabulary as policy lookup (found / none / temperror / permerror), with no_record? for "none". Unknown TXT fields on the name are ignored; only mailto: and https: destinations are kept.
Custom Resolver & Fetcher
MtaSts.lookup("example.com", known: nil,
resolver: MyCachingResolver.new, # DNS, for discovery
fetcher: MyPolicyFetcher.new) # the HTTPS GET
- resolver - needs one method,
txt, overdnsrubyby default. Covers discovery only (_mta-stsand_smtp._tls). RaiseNotFoundfor no such record,TimeoutorServerFailurefor no answer. The policy host itself is resolved by the HTTP stack inside the fetcher. - fetcher - one
GET, over stdlibNet::HTTPby default, certificate verification on. Body size and a total wall-clock budget are capped so a hostile host cannot hold the thread forever.
Publishing a Policy
policy = MtaSts::Policy.new(mode: :enforce, mx: ["mx.example.com"], max_age: 604800)
puts policy.to_s
version: STSv1
mode: enforce
mx: mx.example.com
max_age: 604800
And read one back:
MtaSts::Policy.parse(File.read("mta-sts.txt"))
# => #<MtaSts::Policy mode: :enforce, mx: ["mx.example.com"], max_age: 604800>
Command Line
mta-sts gmail.com alt1.gmail-smtp-in.l.google.com
domain: gmail.com
status: found
comment: fetched policy id 20190429T010101 from mta-sts.gmail.com
mode: enforce
id: 20190429T010101
max_age: 86400
expires: 2026-07-29 15:51:41 UTC
mx:
smtp.google.com
gmail-smtp-in.l.google.com
*.gmail-smtp-in.l.google.com
rua:
mailto:tlsrpt@smtp.gmail.com
alt1.gmail-smtp-in.l.google.com: accepted
Exit codes answer the MTA-STS question only. TLSRPT is printed for diagnostics and never changes the exit:
0- answered, a policy vouches for the MX, or the domain publishes none1- refused, a policy applies and does not name it2- unknown, DNS or the policy host didn't answer, or what it served was broken64- usage
What It Is Strict About
- HTTP 3xx are never followed
- HTTP caching (RFC 7234) is never used. Freshness comes from the TXT
idandmax_ageonly - Only
200is a policy - The certificate is verified against
mta-sts.<domain>, the policy host name - The
_mta-stsdiscovery record must match the ABNF (v=STSv1;case-sensitive, a single well-formedid) - Fetch body size and total time are capped
What It Is Not That Strict
Content-Type: text/plainis checked but not enforced- Line terminators may be bare
LFas well asCRLF
What It Does Not Check
- **Certificate revocation.**
Net::HTTPdoesn't check it, and this library doesn't wire up OCSP or CRL. Pass your ownfetcherif you need it. - **The address the policy host resolves to.**
Fetched at whatever address DNS returns. The certificate check is what protects you here. - **DANE.**
A separate mechanism needing DNSSEC validation and the peer certificate mid-handshake, which this library never sees. - **TLSRPT report generation or submission.**
This library finds therua; building and sending aggregate reports is the application's job. - **Anything about the SMTP connection.**
This library opens one socket, to fetch a policy over HTTPS. It never speaks SMTP.
Testing
bundle install
rake
History
View the changelog.
Contributing
Everyone is encouraged to help improve this project:
- Report bugs
- Fix bugs and submit pull requests
- Write, clarify, or fix documentation
- Suggest or add new features
License
MIT. See LICENSE.