mta_sts
🔒 SMTP MTA Strict Transport Security for Ruby
Answers one question before a message goes out: must this connection be encrypted, and to which servers?
Resolve a recipient domain's MTA-STS policy (RFC 8461) and find out which of its mail servers may be used and whether TLS is optional. Also discovers where the domain wants TLS failure reports sent (RFC 8460 TLSRPT) — discovery only; generating and submitting reports is yours.
Installation
Add this line to your application's Gemfile:
gem "mta_sts"And run:
bundle installLook up your first policy with:
require "mta_sts"
result = MtaSts.lookup("gmail.com", known: nil)
result.enforce? # => trueHow 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
endHand 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") # => falseTwo 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 unchangedid -
none/no_record?- no_mta-stsrecord seen -
temperror/temperror?- DNS or the policy host didn't answer -
permerror/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
endtemperror 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.policySkip 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_sversion: 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.comdomain: 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 none -
1- refused, a policy applies and does not name it -
2- unknown, DNS or the policy host didn't answer, or what it served was broken -
64- 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 - §3.2 says SHOULD, not MUST, and real policies in the wild omit it - 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 the
rua; 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
rakeNothing leaves the machine. Every lookup takes a resolver: and a fetcher:, and the test doubles stand a hash in for DNS and a string in for the policy host.
History
View the changelog
Contributing
Everyone is encouraged to help improve this project. Here are a few ways you can help:
- Report bugs
- Fix bugs and submit pull requests
- Write, clarify, or fix documentation
- Suggest or add new features
To get started with development:
git clone https://github.com/mailpiece/mta_sts.git
cd mta_sts
bundle install
bundle exec rakeclLicense
MIT. See LICENSE.