mta_sts 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d9411774536a59bfad560263255edf3e3d7d72d79fb943a6033027de9288dc9f
4
+ data.tar.gz: 272a5f2abd617892cec5cd21279300fa00b52fbaec16686c977657d6b9a37ddc
5
+ SHA512:
6
+ metadata.gz: a3c37c313c46186d4073cc8576ce88e588f3b02aaf35c1f9b305df98a6ca82111c78efdad642a5f2fa755f7a3e9e27590dcfa5f04c0068d70b9301e3a37c4840
7
+ data.tar.gz: 5edd1bc7ff60ea8d161a8627ac90ba8b50d44d8f6938787f3b3dbb16512c90b750ec682acc32177457c11d64617045bf2a18c15e9d9e196017b899d194389272
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0
4
+
5
+ - The fetch ignores a proxy from the environment.
6
+ - The fetch's total timeout also bounds the response headers.
7
+
8
+ ## 0.5.0
9
+
10
+ - Initial public release.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PostRider
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,262 @@
1
+ # mta_sts
2
+
3
+ 🔒 SMTP MTA Strict Transport Security for Ruby.
4
+
5
+ Look up a recipient domain's MTA-STS policy before delivering a message, to know whether TLS must be enforced and which servers are legitimate.
6
+
7
+ **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).
8
+
9
+ For outbound MTAs deciding how to deliver a message: mail servers refusing to downgrade to plaintext or connect to an unlisted host, senders discovering where to submit TLSRPT aggregate reports.
10
+
11
+ **mta_sts** handles:
12
+
13
+ - MTA-STS policy lookup (RFC 8461) - which mail servers a domain vouches for, and whether TLS is optional or must be enforced
14
+ - status and policy kept as separate axes, so a DNS timeout can't be mistaken for a domain that dropped its policy
15
+ - policy persistence handled by the caller - hand back what you kept last time, and get told what to keep next
16
+ - TLSRPT record discovery (RFC 8460) - where a domain wants TLS failure reports sent
17
+ - injectable resolver and fetcher, with the fetcher validating the resolved address before it dials, since the address is a stranger's choosing
18
+ - policy publishing and parsing helpers, for domains serving their own policy
19
+
20
+ > Resolves a policy and tells you whether to trust a server. What you do with that answer - defer, refuse, downgrade - stays in your code.
21
+
22
+ ## Contents
23
+
24
+ - [Installation](#installation)
25
+ - [Looking Up a Policy](#looking-up-a-policy)
26
+ - [Results](#results)
27
+ - [Status and Policy Are Separate Axes](#status-and-policy-are-separate-axes)
28
+ - [Holding On to a Policy](#holding-on-to-a-policy)
29
+ - [TLSRPT Record Discovery](#tlsrpt-record-discovery)
30
+ - [Custom Resolver & Fetcher](#custom-resolver--fetcher)
31
+ - [Publishing a Policy](#publishing-a-policy)
32
+ - [What It Is Strict About](#what-it-is-strict-about)
33
+ - [What It Is Not That Strict](#what-it-is-not-that-strict)
34
+ - [What It Does Not Check](#what-it-does-not-check)
35
+ - [Testing](#testing)
36
+ - [History](#history)
37
+ - [Contributing](#contributing)
38
+ - [Acknowledgments](#acknowledgments)
39
+ - [License](#license)
40
+
41
+ ## Installation
42
+
43
+ Add this line to your application's Gemfile:
44
+
45
+ ```ruby
46
+ gem "mta_sts"
47
+ ```
48
+
49
+ Requires Ruby >= 3.4
50
+
51
+ ## Looking Up a Policy
52
+
53
+ ```ruby
54
+ result = MtaSts.lookup(domain, known:, resolver:, fetcher:, now:)
55
+ # => #<MtaSts::Result status:, comment:, policy:>
56
+ ```
57
+
58
+ - **domain** - the recipient domain, the part after the `@`
59
+ - **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: nil` should be a decision, not an omission
60
+ - **resolver** - defaults to `MailResolver::Resolver.new`, from the [mailresolver](https://github.com/mailpiece/mailresolver) gem
61
+ - **fetcher** - defaults to `MtaSts::Fetcher.new`
62
+ - **now** - a `Time`, for expiry; defaults to `Time.now`
63
+
64
+ ```ruby
65
+ result = MtaSts.lookup("gmail.com", known: store["gmail.com"])
66
+ store["gmail.com"] = result.policy if result.policy
67
+
68
+ if result.enforce? && !result.allows?(mx_host)
69
+ # the policy names which servers are legitimate, and this is not one of them
70
+ end
71
+ ```
72
+
73
+ Hand back what you kept, keep what comes back - both halves matter.
74
+
75
+ ## Results
76
+
77
+ ```ruby
78
+ result.status # => "found"
79
+ result.policy.mode # => :enforce
80
+ result.policy.mx # => ["gmail-smtp-in.l.google.com", "*.gmail-smtp-in.l.google.com"]
81
+ result.policy.max_age # => 86400
82
+ result.policy.id # => "20190429T010101"
83
+ result.policy.domain # => "gmail.com"
84
+ result.policy.expires_at # => 2026-07-29 14:03:11 UTC
85
+
86
+ result.enforce? # => true
87
+ result.allows?("alt1.gmail-smtp-in.l.google.com") # => true
88
+ result.allows?("mx.attacker.example") # => false
89
+ ```
90
+
91
+ 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?`).
92
+
93
+ ## Status and Policy Are Separate Axes
94
+
95
+ `status` describes what this lookup learned. `policy` describes what you must honor. Nothing raises - a result always comes back with a `comment`:
96
+
97
+ ```ruby
98
+ result.status # => "temperror"
99
+ result.comment # => "DNS didn't answer for _mta-sts.example.com"
100
+ ```
101
+
102
+ Four statuses, each with a predicate:
103
+
104
+ - `found` / `found?` - a policy was fetched, or renewed against an unchanged `id`
105
+ - `none` / `no_record?` - no `_mta-sts` record seen
106
+ - `temperror` / `temperror?` - DNS or the policy host didn't answer
107
+ - `permerror` / `permerror?` - the record or policy file is broken
108
+
109
+ `policy` is present whenever there's one to honor: fresh, renewed, or the `known` one you passed in that hasn't expired.
110
+
111
+ | what happened | status | policy | `enforce?` |
112
+ | ------------------------------------------ | ----------- | ------- | ---------- |
113
+ | fresh fetch, `mode: enforce` | `found` | present | true |
114
+ | DNS timeout, held an enforcing policy | `temperror` | held | true |
115
+ | TXT record gone, held an enforcing policy | `none` | held | **true** |
116
+ | never had one, no TXT record | `none` | nil | false |
117
+
118
+ 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.
119
+
120
+ Use `enforce?` and `allows?` for the connection decision, not `status`:
121
+
122
+ ```ruby
123
+ if result.enforce? && !result.allows?(mx)
124
+ defer_or_fail
125
+ elsif result.temperror? && !result.policy
126
+ defer # we don't know; don't downgrade
127
+ end
128
+ ```
129
+
130
+ `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.
131
+
132
+ ## Holding On to a Policy
133
+
134
+ There's no cache in this library. `lookup` takes the policy you kept last time and hands one back.
135
+
136
+ ```ruby
137
+ result = MtaSts.lookup("example.com", known: store["example.com"])
138
+ store["example.com"] = result.policy if result.policy
139
+ ```
140
+
141
+ Skip that round trip and §5.1 doesn't apply. Three rules govern what comes back:
142
+
143
+ - A matching `id` renews the expiry without refetching
144
+ - A temporary failure keeps a policy that hasn't expired
145
+ - A fetched `mode: none` replaces it immediately
146
+
147
+ 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:
148
+
149
+ ```ruby
150
+ row = { body: policy.to_s, id: policy.id, domain: policy.domain, fetched_at: policy.fetched_at }
151
+ MtaSts::Policy.parse(row[:body], id: row[:id], domain: row[:domain], fetched_at: row[:fetched_at])
152
+ ```
153
+
154
+ ## TLSRPT Record Discovery
155
+
156
+ Where a domain wants TLS failure reports sent (RFC 8460). One TXT lookup at `_smtp._tls.<domain>` — not report generation or submission.
157
+
158
+ ```ruby
159
+ result = MtaSts.reporting("gmail.com")
160
+ # => #<MtaSts::Report::Result status:, comment:, report:>
161
+
162
+ result.status # => "found"
163
+ result.rua # => ["mailto:tlsrpt@smtp.gmail.com"]
164
+ result.report.mailto
165
+ result.report.https
166
+ result.report.ignored # destinations that were named but are neither mailto: nor https:
167
+ ```
168
+
169
+ 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.
170
+
171
+ ## Custom Resolver & Fetcher
172
+
173
+ ```ruby
174
+ MtaSts.lookup("example.com", known: nil,
175
+ resolver: MyCachingResolver.new, # DNS, for discovery
176
+ fetcher: MyPolicyFetcher.new) # the HTTPS GET
177
+ ```
178
+
179
+ - **resolver** - needs one method, `txt`, over [mailresolver](https://github.com/mailpiece/mailresolver) by default. Covers discovery only (`_mta-sts` and `_smtp._tls`). Raise `MailResolver::NotFound` for no such record, `MailResolver::Timeout` or `MailResolver::ServerFailure` for no answer. If your app also uses `mailauth` or talks to DNS itself, build one `MailResolver::Resolver` and pass it everywhere — see mailresolver's README for why.
180
+ - **fetcher** - one `GET`, over stdlib `Net::HTTP` by default, certificate verification on. Body size and a total wall-clock budget are capped so a hostile host cannot hold the thread forever.
181
+
182
+ The fetcher resolves the policy host itself, so that the address it dials is one it has judged:
183
+
184
+ ```ruby
185
+ MtaSts::Fetcher.new(
186
+ resolver: MyResolver.new, # needs `addresses` as well as `txt`
187
+ permitted: ->(address) { my_own_opinion(address) })
188
+ ```
189
+
190
+ - **permitted** - asked about each resolved address; each one admitted is dialled in turn until the host answers. Defaults to `MtaSts::Fetcher.public_address?`, which admits public unicast and refuses private, loopback, link-local, and other special-use addresses, including ones wrapped or reached through NAT64. A deployment serving policies from inside its own network passes something more permissive.
191
+
192
+ The recipient domain names the policy host, so where it resolves is chosen by whoever is being sent mail — without this, that's a request to an address of a stranger's choosing.
193
+
194
+ ## Publishing a Policy
195
+
196
+ ```ruby
197
+ policy = MtaSts::Policy.new(mode: :enforce, mx: ["mx.example.com"], max_age: 604800)
198
+ puts policy.to_s
199
+ ```
200
+ version: STSv1
201
+ mode: enforce
202
+ mx: mx.example.com
203
+ max_age: 604800
204
+
205
+
206
+ And read one back:
207
+
208
+ ```ruby
209
+ MtaSts::Policy.parse(File.read("mta-sts.txt"))
210
+ # => #<MtaSts::Policy mode: :enforce, mx: ["mx.example.com"], max_age: 604800>
211
+ ```
212
+
213
+ ## What It Is Strict About
214
+
215
+ - HTTP 3xx are never followed
216
+ - HTTP caching (RFC 7234) is never used. Freshness comes from the TXT `id` and `max_age` only
217
+ - Only `200` is a policy
218
+ - The certificate is verified against `mta-sts.<domain>`, the policy host name
219
+ - The `_mta-sts` discovery record must match the ABNF (`v=STSv1;` case-sensitive, a single well-formed `id`)
220
+ - Fetch body size and total time are capped
221
+
222
+ ## What It Is Not That Strict
223
+
224
+ - `Content-Type: text/plain` is checked but not enforced
225
+ - Line terminators may be bare `LF` as well as `CRLF`
226
+ - The policy file is read case-insensitively, so `version: stsv1` and `mode: ENFORCE` both parse, though the ABNF spells both case-sensitively. The discovery record is not read this way: strictness is worth more on the record that decides whether a policy exists at all than on the document it leads to
227
+
228
+ ## What It Does Not Check
229
+
230
+ - **Certificate revocation.** `Net::HTTP` doesn't check it, and this library doesn't wire up OCSP or CRL. Pass your own `fetcher` if you need it.
231
+ - **The address the policy host resolves to.** Fetched at whatever address DNS returns. The certificate check is what protects you here.
232
+ - **DANE.** A separate mechanism needing DNSSEC validation and the peer certificate mid-handshake, which this library never sees.
233
+ - **TLSRPT report generation or submission.** This library finds the `rua`; building and sending aggregate reports is the application's job.
234
+ - **Anything about the SMTP connection.** This library opens one socket, to fetch a policy over HTTPS. It never speaks SMTP.
235
+
236
+ ## Testing
237
+
238
+ ```sh
239
+ bundle install
240
+ rake
241
+ ```
242
+
243
+ ## History
244
+
245
+ View the [changelog](CHANGELOG.md).
246
+
247
+ ## Contributing
248
+
249
+ Everyone is encouraged to help improve this project:
250
+
251
+ - [Report bugs](https://github.com/mailpiece/mta_sts/issues)
252
+ - Fix bugs and submit pull requests
253
+ - Write, clarify, or fix documentation
254
+ - Suggest or add new features
255
+
256
+ ## Acknowledgments
257
+
258
+ View the [acknowledgments](ACKNOWLEDGMENTS.md).
259
+
260
+ ## License
261
+
262
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,262 @@
1
+ require "ipaddr"
2
+ require "net/http"
3
+ require "openssl"
4
+ require "zlib"
5
+ require "mailresolver"
6
+ require "mta_sts/version"
7
+
8
+ module MtaSts
9
+ # HTTPS GET of mta-sts.<domain>/.well-known/mta-sts.txt (RFC 8461 §3.3)
10
+ class Fetcher
11
+ class Error < StandardError; end
12
+ class HttpError < Error; end
13
+ class CertificateError < Error; end
14
+ # A temperror like any other failed fetch, so a policy already held stands.
15
+ class BlockedAddress < Error; end
16
+ class Timeout < Error; end
17
+
18
+ # A failure the policy host never answered through
19
+ class Unanswered < StandardError
20
+ attr_reader :failure
21
+
22
+ def initialize(failure)
23
+ super(failure.message)
24
+ @failure = failure
25
+ end
26
+ end
27
+ private_constant :Unanswered
28
+
29
+ WELL_KNOWN_PATH = "/.well-known/mta-sts.txt"
30
+ PORT = 443
31
+ OPEN_TIMEOUT = 10
32
+ READ_TIMEOUT = 10
33
+ # read_timeout renews per read; this one does not — stops a slow-drip body
34
+ TOTAL_TIMEOUT = 30
35
+ MAX_BODY_BYTES = 64 * 1024
36
+ USER_AGENT = "mta_sts/#{VERSION}"
37
+
38
+ # RFC 8461 §3.2 SHOULD text/plain — reported, not enforced
39
+ class Body < String
40
+ attr_reader :content_type
41
+
42
+ def initialize(body, content_type: nil)
43
+ super(body)
44
+ @content_type = content_type
45
+ end
46
+
47
+ def text_plain?
48
+ content_type.to_s.split(";").first.to_s.strip.casecmp?("text/plain")
49
+ end
50
+ end
51
+
52
+ # Special-use IPv4 no policy is published in. RFC 1918, loopback and
53
+ # link-local are left to the IPAddr predicates.
54
+ RESERVED_V4 = %w[
55
+ 0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24
56
+ 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
57
+ ].map { |range| IPAddr.new(range) }.freeze
58
+
59
+ RESERVED_V6 = %w[
60
+ ::/128 64:ff9b:1::/48 100::/64 2001::/32 2001:2::/48 2001:db8::/32
61
+ 2002::/16 fec0::/10 ff00::/8
62
+ ].map { |range| IPAddr.new(range) }.freeze
63
+
64
+ # RFC 6052 §2.2 with a /96 prefix
65
+ NAT64 = IPAddr.new("64:ff9b::/96")
66
+
67
+ class << self
68
+ # The policy host is named by the domain being delivered to, so where it
69
+ # resolves is a stranger's to choose.
70
+ def public_address?(address)
71
+ ip = address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
72
+
73
+ case
74
+ # No resolver legitimately answers with an address wrapped in another,
75
+ # whatever it wraps.
76
+ when ip.ipv4_mapped? || ipv4_compat?(ip) then false
77
+ when ip.ipv4? then public_v4?(ip)
78
+ when NAT64.include?(ip) then public_v4?(embedded_v4(ip))
79
+ else public_v6?(ip)
80
+ end
81
+ rescue IPAddr::Error
82
+ false
83
+ end
84
+
85
+ private
86
+ # IPAddr#ipv4_compat? is obsolete; reimplemented since the check itself
87
+ # (the deprecated ::a.b.c.d form) is still one we need to catch.
88
+ def ipv4_compat?(ip)
89
+ if ip.ipv6?
90
+ low = ip.to_i & 0xffffffff
91
+ (ip.to_i >> 32).zero? && low != 0 && low != 1
92
+ else
93
+ false
94
+ end
95
+ end
96
+
97
+ def public_v4?(ip)
98
+ !(ip.private? || ip.loopback? || ip.link_local? || covered_by?(RESERVED_V4, ip))
99
+ end
100
+
101
+ def embedded_v4(ip)
102
+ IPAddr.new(ip.to_i & 0xffffffff, Socket::AF_INET)
103
+ end
104
+
105
+ def public_v6?(ip)
106
+ !(ip.private? || ip.loopback? || ip.link_local? || covered_by?(RESERVED_V6, ip))
107
+ end
108
+
109
+ def covered_by?(ranges, ip)
110
+ ranges.any? { |range| range.include?(ip) }
111
+ end
112
+ end
113
+
114
+ def initialize(open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT,
115
+ total_timeout: TOTAL_TIMEOUT, max_body_bytes: MAX_BODY_BYTES,
116
+ resolver: MailResolver::Resolver.new, permitted: Fetcher.method(:public_address?))
117
+ @open_timeout = open_timeout
118
+ @read_timeout = read_timeout
119
+ @total_timeout = total_timeout
120
+ @max_body_bytes = max_body_bytes
121
+ @resolver = resolver
122
+ @permitted = permitted
123
+ end
124
+
125
+ def fetch(host)
126
+ get(normalize(host))
127
+ end
128
+
129
+ private
130
+ def normalize(host)
131
+ host.to_s.downcase.chomp(".")
132
+ end
133
+
134
+ def get(host)
135
+ deadline = monotonic_now + @total_timeout
136
+ failure = nil
137
+
138
+ # A policy host with several addresses isn't written off because the first
139
+ # is down — the domain published all of them, and any one serves.
140
+ permitted_addresses(host).each do |address|
141
+ remaining = deadline - monotonic_now
142
+ break unless remaining.positive?
143
+
144
+ begin
145
+ return policy_from(host, address, deadline, remaining)
146
+ rescue Unanswered => unanswered
147
+ failure = unanswered.failure
148
+ end
149
+ end
150
+
151
+ raise failure || Timeout.new("timed out fetching the policy from #{host}")
152
+ end
153
+
154
+ # Resolved once here and carried into every connection: resolving again to
155
+ # dial would leave the second answer free to differ from the one judged.
156
+ def permitted_addresses(host)
157
+ addresses = resolve(host).select { |address| @permitted.call(address) }
158
+ raise BlockedAddress, "#{host} resolves to no address a policy may be published at" if addresses.empty?
159
+
160
+ addresses
161
+ end
162
+
163
+ def resolve(host)
164
+ addresses = @resolver.addresses(host)
165
+ raise Error, "couldn't resolve #{host}" if addresses.empty?
166
+
167
+ addresses
168
+ rescue MailResolver::Error => error
169
+ raise Error, "couldn't resolve #{host}: #{error.message}"
170
+ end
171
+
172
+ def policy_from(host, address, deadline, remaining)
173
+ with_response(host, address, remaining) do |response|
174
+ if response.is_a?(Net::HTTPOK)
175
+ body_from(response, host, deadline)
176
+ else
177
+ # RFC 8461 §3.3 MUST NOT follow redirects
178
+ raise HttpError, "#{host} answered #{response.code}"
179
+ end
180
+ end
181
+ end
182
+
183
+ # `answered` is the whole distinction the attempt loop turns on. read_timeout
184
+ # renews with every header line, so the deadline also bounds the headers.
185
+ def with_response(host, address, remaining)
186
+ answered = false
187
+ result = nil
188
+
189
+ ::Timeout.timeout(remaining) do
190
+ connection(host, address).start do |http|
191
+ http.request(policy_request) do |response|
192
+ answered = true
193
+ result = yield response
194
+ end
195
+ end
196
+ end
197
+
198
+ result
199
+ rescue OpenSSL::SSL::SSLError => error
200
+ raise attempt_failure(answered, CertificateError.new("#{host} presented a certificate that didn't verify: #{error.message}"))
201
+ rescue Net::OpenTimeout, Net::ReadTimeout
202
+ raise attempt_failure(answered, Timeout.new("timed out fetching the policy from #{host}"))
203
+ rescue ::Timeout::Error
204
+ raise attempt_failure(answered, Timeout.new("#{host} took longer than #{@total_timeout} seconds to send the policy"))
205
+ rescue Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error => error
206
+ raise attempt_failure(answered, HttpError.new("#{host} answered with malformed HTTP: #{error.message}"))
207
+ rescue SystemCallError, SocketError, IOError, EOFError => error
208
+ raise attempt_failure(answered, Error.new("couldn't reach #{host}: #{error.message}"))
209
+ end
210
+
211
+ def connection(host, address)
212
+ # The nil proxy ignores http_proxy: a proxy would resolve the name again,
213
+ # and the connection would no longer go to the address judged.
214
+ Net::HTTP.new(host, PORT, nil).tap do |http|
215
+ # Dial the address already judged; the name stays behind for SNI, the
216
+ # Host header, and the certificate the whole fetch rests on.
217
+ http.ipaddr = address
218
+ http.use_ssl = true
219
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
220
+ http.verify_hostname = true
221
+ http.min_version = OpenSSL::SSL::TLS1_2_VERSION
222
+ http.open_timeout = @open_timeout
223
+ http.read_timeout = @read_timeout
224
+ end
225
+ end
226
+
227
+ # RFC 8461 §3.3 — no HTTP caching; freshness is TXT id + max_age
228
+ def policy_request
229
+ Net::HTTP::Get.new(WELL_KNOWN_PATH, "accept" => "text/plain", "user-agent" => USER_AGENT)
230
+ end
231
+
232
+ def attempt_failure(answered, error)
233
+ if answered
234
+ error
235
+ else
236
+ Unanswered.new(error)
237
+ end
238
+ end
239
+
240
+ def body_from(response, host, deadline)
241
+ body = +""
242
+
243
+ response.read_body do |chunk|
244
+ body << chunk
245
+
246
+ if body.bytesize > @max_body_bytes
247
+ raise HttpError, "#{host} sent more than #{@max_body_bytes} bytes of policy"
248
+ end
249
+
250
+ if monotonic_now > deadline
251
+ raise Timeout, "#{host} took longer than #{@total_timeout} seconds to send the policy"
252
+ end
253
+ end
254
+
255
+ Body.new(body.force_encoding(Encoding::UTF_8), content_type: response["content-type"])
256
+ end
257
+
258
+ def monotonic_now
259
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,179 @@
1
+ require "simpleidn"
2
+
3
+ module MtaSts
4
+ class Policy
5
+ class Invalid < StandardError; end
6
+
7
+ POLICY_VERSION = "STSv1"
8
+ MODES = %i[ none testing enforce ].freeze
9
+ MAX_AGE = (0..31_557_600) # RFC 8461 §3.2
10
+ WILDCARD = "*."
11
+ TERMINATOR = /\r?\n/ # RFC 8461 §3.2 ABNF: sts-policy-term = LF / CRLF
12
+ DIGITS = /\A\d+\z/
13
+
14
+ class << self
15
+ def parse(text, id: nil, domain: nil, fetched_at: Time.now)
16
+ fields = fields_in(text)
17
+
18
+ validate_version single(fields, "version")
19
+
20
+ new mode: single(fields, "mode"), mx: fields.fetch("mx", []), max_age: single(fields, "max_age"),
21
+ id: id, domain: domain, fetched_at: fetched_at
22
+ end
23
+
24
+ def normalize(name)
25
+ name = scrubbed(name).strip.chomp(".")
26
+
27
+ if name.start_with?(WILDCARD)
28
+ WILDCARD + ascii(name.delete_prefix(WILDCARD)).downcase
29
+ else
30
+ ascii(name).downcase
31
+ end
32
+ end
33
+
34
+ private
35
+ # RFC 8461 §3.2 — ignore unrecognized lines
36
+ def fields_in(text)
37
+ validated_document(text).split(TERMINATOR).each_with_object({}) do |line, fields|
38
+ name, value = line.split(":", 2)
39
+
40
+ (fields[name.strip.downcase] ||= []) << value.strip if value
41
+ end
42
+ end
43
+
44
+ def validated_document(text)
45
+ text = text.to_s
46
+
47
+ if text.valid_encoding?
48
+ text
49
+ else
50
+ raise Invalid, "policy is not valid #{text.encoding}"
51
+ end
52
+ end
53
+
54
+ # RFC 8461 §3.2 - a duplicated non-mx field: keep the first, ignore the rest.
55
+ def single(fields, name)
56
+ fields.fetch(name, []).first
57
+ end
58
+
59
+ def validate_version(version)
60
+ unless version&.casecmp?(POLICY_VERSION)
61
+ raise Invalid, "version must be #{POLICY_VERSION}, not #{version.inspect}"
62
+ end
63
+ end
64
+
65
+ def scrubbed(name)
66
+ name = name.to_s
67
+ name.valid_encoding? ? name : name.scrub
68
+ end
69
+
70
+ def ascii(name)
71
+ name.ascii_only? ? name : SimpleIDN.to_ascii(name)
72
+ rescue StandardError
73
+ name
74
+ end
75
+ end
76
+
77
+ attr_reader :mode, :mx, :max_age, :id, :domain, :fetched_at
78
+
79
+ def initialize(mode:, max_age:, mx: [], id: nil, domain: nil, fetched_at: Time.now)
80
+ @mode = validated_mode(mode)
81
+ @mx = validated_mx(mx)
82
+ @max_age = validated_max_age(max_age)
83
+ @domain = domain && self.class.normalize(domain)
84
+ @id, @fetched_at = id, fetched_at
85
+ end
86
+
87
+ def none?
88
+ mode == :none
89
+ end
90
+
91
+ def testing?
92
+ mode == :testing
93
+ end
94
+
95
+ def enforce?
96
+ mode == :enforce
97
+ end
98
+
99
+ def allows?(host)
100
+ if none?
101
+ true
102
+ else
103
+ host = self.class.normalize(host)
104
+
105
+ mx.any? { |pattern| matches?(pattern, host) }
106
+ end
107
+ end
108
+
109
+ def expires_at
110
+ fetched_at + max_age
111
+ end
112
+
113
+ def expired?(now = Time.now)
114
+ now >= expires_at
115
+ end
116
+
117
+ def to_s
118
+ lines = [ "version: #{POLICY_VERSION}", "mode: #{mode}" ]
119
+ lines.concat mx.map { |pattern| "mx: #{pattern}" }
120
+ lines << "max_age: #{max_age}"
121
+
122
+ lines.map { |line| "#{line}\r\n" }.join
123
+ end
124
+
125
+ def inspect
126
+ "#<#{self.class.name} mode: #{mode.inspect}, mx: #{mx.inspect}, max_age: #{max_age.inspect}>"
127
+ end
128
+
129
+ private
130
+ def validated_mode(mode)
131
+ if known = MODES.find { |candidate| candidate.to_s == mode.to_s.strip.downcase }
132
+ known
133
+ else
134
+ raise Invalid, "mode must be one of #{MODES.join(", ")}, not #{mode.inspect}"
135
+ end
136
+ end
137
+
138
+ # RFC 8461 §3.2 — at least one mx unless mode is none
139
+ def validated_mx(mx)
140
+ patterns = Array(mx).map { |pattern| validated_pattern(pattern) }.uniq
141
+
142
+ if patterns.empty? && !none?
143
+ raise Invalid, "mx must name at least one host unless mode is none"
144
+ else
145
+ patterns
146
+ end
147
+ end
148
+
149
+ def validated_pattern(pattern)
150
+ normalized = self.class.normalize(pattern)
151
+
152
+ if normalized.delete("*.").empty?
153
+ raise Invalid, "mx must name a host, not #{pattern.inspect}"
154
+ else
155
+ normalized
156
+ end
157
+ end
158
+
159
+ def validated_max_age(max_age)
160
+ # grammar is 1*DIGIT — not Integer(), which accepts 0x10 / 1_0
161
+ if max_age.to_s.match?(DIGITS) && MAX_AGE.cover?(seconds = max_age.to_i)
162
+ seconds
163
+ else
164
+ raise Invalid, "max_age must be a whole number of seconds in #{MAX_AGE}, not #{max_age.inspect}"
165
+ end
166
+ end
167
+
168
+ # RFC 8461 §4.1 — "*." covers exactly one label
169
+ def matches?(pattern, host)
170
+ if pattern.start_with?(WILDCARD)
171
+ label, _, parent = host.partition(".")
172
+
173
+ !label.empty? && parent == pattern.delete_prefix(WILDCARD)
174
+ else
175
+ pattern == host
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,35 @@
1
+ module MtaSts
2
+ class Report
3
+ class Result
4
+ attr_reader :status, :report, :comment
5
+
6
+ def initialize(status:, report: nil, comment: nil)
7
+ @status, @report, @comment = status, report, comment
8
+ end
9
+
10
+ def found?
11
+ status == "found"
12
+ end
13
+
14
+ def no_record?
15
+ status == "none"
16
+ end
17
+
18
+ def temperror?
19
+ status == "temperror"
20
+ end
21
+
22
+ def permerror?
23
+ status == "permerror"
24
+ end
25
+
26
+ def rua
27
+ report&.rua || []
28
+ end
29
+
30
+ def inspect
31
+ "#<#{self.class.name} status: #{status.inspect}, report: #{report.inspect}, comment: #{comment.inspect}>"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,105 @@
1
+ require "mta_sts/policy"
2
+ require "mta_sts/report/result"
3
+
4
+ module MtaSts
5
+ class Report
6
+ class Invalid < StandardError; end
7
+
8
+ REPORT_VERSION = "TLSRPTv1"
9
+ # RFC 8460 §3 has the same ABNF/prose contradiction as RFC 8461 §3.1 (see
10
+ # STS_RECORD) — the literal "v=TLSRPTv1;" prefix is what's enforced.
11
+ REPORT_RECORD = /\Av=#{REPORT_VERSION};/
12
+ FIELD_DELIM = ";"
13
+ RUA_FIELD = "rua=" # RFC 8460 §3
14
+ URI_DELIM = ","
15
+ MAILTO = /\Amailto:/i # RFC 3986 §3.1 — scheme is case-insensitive
16
+ HTTPS = /\Ahttps:/i
17
+ SCHEMES = [ MAILTO, HTTPS ].freeze
18
+
19
+ class << self
20
+ def parse(text, domain: nil)
21
+ new rua: uris_in(fields_in(text)), domain: domain
22
+ end
23
+
24
+ private
25
+ def fields_in(text)
26
+ fields = validated_record(text).split(FIELD_DELIM).drop(1).map(&:strip)
27
+
28
+ if fields.empty?
29
+ raise Invalid, "record must carry at least one field after v=#{REPORT_VERSION}"
30
+ else
31
+ fields
32
+ end
33
+ end
34
+
35
+ def validated_record(text)
36
+ text = validated_encoding(text)
37
+
38
+ if text.match?(REPORT_RECORD)
39
+ text
40
+ else
41
+ raise Invalid, "record must begin with v=#{REPORT_VERSION};, not #{text.inspect}"
42
+ end
43
+ end
44
+
45
+ def validated_encoding(text)
46
+ text = text.to_s
47
+
48
+ if text.valid_encoding?
49
+ text
50
+ else
51
+ raise Invalid, "record is not valid #{text.encoding}"
52
+ end
53
+ end
54
+
55
+ # RFC 8460 §3 — unknown fields ignored; multiple rua fields allowed
56
+ def uris_in(fields)
57
+ rua_fields = fields.select { |field| field.start_with?(RUA_FIELD) }
58
+
59
+ rua_fields.flat_map { |field| destinations_in(field.delete_prefix(RUA_FIELD)) }
60
+ end
61
+
62
+ def destinations_in(value)
63
+ value.split(URI_DELIM, -1).map(&:strip)
64
+ end
65
+ end
66
+
67
+ attr_reader :rua, :ignored, :domain
68
+
69
+ def initialize(rua:, domain: nil)
70
+ @rua, @ignored = validated_destinations(rua)
71
+ @domain = domain && Policy.normalize(domain)
72
+ end
73
+
74
+ def mailto
75
+ rua.grep(MAILTO)
76
+ end
77
+
78
+ def https
79
+ rua.grep(HTTPS)
80
+ end
81
+
82
+ def to_s
83
+ "v=#{REPORT_VERSION}; #{RUA_FIELD}#{rua.join(", ")}"
84
+ end
85
+
86
+ def inspect
87
+ "#<#{self.class.name} rua: #{rua.inspect}, ignored: #{ignored.inspect}>"
88
+ end
89
+
90
+ private
91
+ def validated_destinations(uris)
92
+ usable, unusable = Array(uris).partition { |uri| supported?(uri) }
93
+
94
+ if usable.empty?
95
+ raise Invalid, "rua must name at least one mailto: or https: destination"
96
+ else
97
+ [ usable, unusable ]
98
+ end
99
+ end
100
+
101
+ def supported?(uri)
102
+ SCHEMES.any? { |scheme| uri.to_s.match?(scheme) }
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,47 @@
1
+ module MtaSts
2
+ # status = what this lookup learned; policy = what to honour (§5.1)
3
+ class Result
4
+ attr_reader :status, :policy, :comment
5
+
6
+ def initialize(status:, policy: nil, comment: nil)
7
+ @status, @policy, @comment = status, policy, comment
8
+ end
9
+
10
+ def found?
11
+ status == "found"
12
+ end
13
+
14
+ # "no TXT this time" — not "no policy applies" (see Policy#none?)
15
+ def no_record?
16
+ status == "none"
17
+ end
18
+
19
+ def temperror?
20
+ status == "temperror"
21
+ end
22
+
23
+ def permerror?
24
+ status == "permerror"
25
+ end
26
+
27
+ def enforce?
28
+ policy&.enforce? || false
29
+ end
30
+
31
+ def testing?
32
+ policy&.testing? || false
33
+ end
34
+
35
+ def allows?(host)
36
+ if policy
37
+ policy.allows?(host)
38
+ else
39
+ true
40
+ end
41
+ end
42
+
43
+ def inspect
44
+ "#<#{self.class.name} status: #{status.inspect}, policy: #{policy.inspect}, comment: #{comment.inspect}>"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,3 @@
1
+ module MtaSts
2
+ VERSION = "0.6.0"
3
+ end
data/lib/mta_sts.rb ADDED
@@ -0,0 +1,153 @@
1
+ require "mailresolver"
2
+ require "mta_sts/version"
3
+ require "mta_sts/fetcher"
4
+ require "mta_sts/policy"
5
+ require "mta_sts/result"
6
+ require "mta_sts/report"
7
+ require "mta_sts/report/result"
8
+
9
+ module MtaSts
10
+ DISCOVERY_PREFIX = "_mta-sts."
11
+ POLICY_HOST_PREFIX = "mta-sts."
12
+ REPORTING_PREFIX = "_smtp._tls." # RFC 8460 §3
13
+
14
+ # RFC 8461 §3.1's ABNF would admit `v=STSv1 ; id=...`, but its prose discards
15
+ # anything not literally starting "v=STSv1;" — the prose is what everyone implements.
16
+ STS_RECORD = /\Av=STSv1;/
17
+ FIELD_DELIM = ";"
18
+ ID_FIELD = /\Aid=(.*)\z/
19
+ # RFC 8461 §3.1 ABNF: sts-id = 1*32(ALPHA / DIGIT)
20
+ STS_ID = /\A[a-zA-Z0-9]{1,32}\z/
21
+
22
+ class << self
23
+ # `known:` is required so omitting it is a decision, not an accident (§5.1).
24
+ def lookup(domain, known:, resolver: MailResolver::Resolver.new, fetcher: Fetcher.new, now: Time.now)
25
+ domain = Policy.normalize(recipient_domain(domain))
26
+ held = usable(known, domain, now)
27
+ id = discover(domain, resolver)
28
+
29
+ case
30
+ when id == :unreachable then unreachable(domain, held)
31
+ when id == :absent then absent(domain, held)
32
+ when held && held.id == id then renewed(domain, held, now)
33
+ else fetched(domain, id, held, fetcher, now)
34
+ end
35
+ end
36
+
37
+ # RFC 8460 §3
38
+ def reporting(domain, resolver: MailResolver::Resolver.new)
39
+ domain = Policy.normalize(recipient_domain(domain))
40
+
41
+ case record = sole_record("#{REPORTING_PREFIX}#{domain}", matching: Report::REPORT_RECORD, resolver: resolver)
42
+ when :unreachable then unanswered(domain)
43
+ when :absent then unpublished(domain)
44
+ else reported(domain, record)
45
+ end
46
+ end
47
+
48
+ private
49
+ def recipient_domain(domain)
50
+ domain.to_s.split("@").last.to_s
51
+ end
52
+
53
+ def usable(policy, domain, now)
54
+ policy if policy && !policy.expired?(now) && belongs_to?(policy, domain)
55
+ end
56
+
57
+ def belongs_to?(policy, domain)
58
+ policy.domain.nil? || policy.domain == domain
59
+ end
60
+
61
+ # RFC 8461 §3.1 — not exactly one usable record means the domain does not implement MTA-STS
62
+ def discover(domain, resolver)
63
+ case record = sole_record("#{DISCOVERY_PREFIX}#{domain}", matching: STS_RECORD, resolver: resolver)
64
+ when :absent, :unreachable then record
65
+ else policy_id(record) || :absent
66
+ end
67
+ end
68
+
69
+ def sole_record(name, matching:, resolver:)
70
+ records = resolver.txt(name).grep(matching)
71
+
72
+ if records.one?
73
+ records.first
74
+ else
75
+ :absent
76
+ end
77
+ rescue MailResolver::NotFound
78
+ :absent
79
+ rescue MailResolver::Error
80
+ :unreachable
81
+ end
82
+
83
+ def policy_id(record)
84
+ ids = record.split(FIELD_DELIM).map(&:strip).filter_map { |field| field[ID_FIELD, 1] }
85
+
86
+ ids.first if ids.one? && ids.first.match?(STS_ID)
87
+ end
88
+
89
+ def unreachable(domain, held)
90
+ outcome(held, status: "temperror",
91
+ comment: "DNS didn't answer for #{DISCOVERY_PREFIX}#{domain}")
92
+ end
93
+
94
+ def absent(domain, held)
95
+ outcome(held, status: "none",
96
+ comment: "#{domain} publishes no MTA-STS policy")
97
+ end
98
+
99
+ # RFC 8461 §5.1 — matching id renews without refetching
100
+ def renewed(domain, held, now)
101
+ policy = Policy.new(mode: held.mode, mx: held.mx, max_age: held.max_age,
102
+ id: held.id, domain: domain, fetched_at: now)
103
+
104
+ found(policy, "policy id #{policy.id} is unchanged; renewed until #{policy.expires_at} without refetching")
105
+ end
106
+
107
+ def fetched(domain, id, held, fetcher, now)
108
+ policy = Policy.parse(fetcher.fetch("#{POLICY_HOST_PREFIX}#{domain}"), id: id, domain: domain, fetched_at: now)
109
+
110
+ found(policy, "fetched policy id #{id} from #{POLICY_HOST_PREFIX}#{domain}")
111
+ rescue Fetcher::Error => error
112
+ outcome(held, status: "temperror",
113
+ comment: "couldn't fetch the policy for #{domain}: #{error.message}")
114
+ rescue Policy::Invalid => error
115
+ outcome(held, status: "permerror",
116
+ comment: "#{POLICY_HOST_PREFIX}#{domain} served a policy that isn't valid: #{error.message}")
117
+ end
118
+
119
+ # §5.1 — only an explicit successful answer may weaken a held policy; status still reports the failure
120
+ def outcome(held, status:, comment:)
121
+ if held
122
+ Result.new(status: status, policy: held,
123
+ comment: "#{comment}; the policy already held stands until #{held.expires_at}")
124
+ else
125
+ Result.new(status: status, comment: comment)
126
+ end
127
+ end
128
+
129
+ def found(policy, comment)
130
+ Result.new(status: "found", policy: policy, comment: comment)
131
+ end
132
+
133
+ def unanswered(domain)
134
+ Report::Result.new(status: "temperror",
135
+ comment: "DNS didn't answer for #{REPORTING_PREFIX}#{domain}")
136
+ end
137
+
138
+ def unpublished(domain)
139
+ Report::Result.new(status: "none",
140
+ comment: "#{domain} publishes no usable TLSRPT record at #{REPORTING_PREFIX}#{domain}")
141
+ end
142
+
143
+ def reported(domain, record)
144
+ report = Report.parse(record, domain: domain)
145
+
146
+ Report::Result.new(status: "found", report: report,
147
+ comment: "#{REPORTING_PREFIX}#{domain} asks for TLS reports at #{report.rua.join(", ")}")
148
+ rescue Report::Invalid => error
149
+ Report::Result.new(status: "permerror",
150
+ comment: "#{REPORTING_PREFIX}#{domain} isn't a usable TLSRPT record: #{error.message}")
151
+ end
152
+ end
153
+ end
data/mta_sts.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ $LOAD_PATH.push File.expand_path('lib', __dir__)
2
+ require_relative "lib/mta_sts/version"
3
+
4
+ Gem::Specification.new do |spec|
5
+ spec.name = "mta_sts"
6
+ spec.version = MtaSts::VERSION
7
+ spec.platform = Gem::Platform::RUBY
8
+ spec.required_ruby_version = ">= 3.4"
9
+ spec.authors = [ "Simon Lev" ]
10
+
11
+ spec.summary = "SMTP MTA Strict Transport Security for Ruby."
12
+ spec.description = "SMTP MTA Strict Transport Security for Ruby. Look up a recipient domain's MTA-STS policy before delivering a message, to know whether TLS must be enforced and which servers are legitimate."
13
+
14
+ spec.homepage = "https://github.com/mailpiece/mta_sts"
15
+ spec.license = "MIT"
16
+
17
+ spec.metadata["source_code_uri"] = spec.homepage
18
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
19
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
20
+ spec.metadata["rubygems_mfa_required"] = "true"
21
+
22
+ spec.files = Dir[
23
+ "lib/**/*.rb",
24
+ "CHANGELOG.md",
25
+ "README.md",
26
+ "LICENSE",
27
+ "mta_sts.gemspec"
28
+ ]
29
+ spec.require_paths = [ "lib" ]
30
+
31
+ spec.add_dependency "mailresolver"
32
+ spec.add_dependency "simpleidn", "~> 0.2"
33
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mta_sts
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.6.0
5
+ platform: ruby
6
+ authors:
7
+ - Simon Lev
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: mailresolver
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: simpleidn
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
40
+ description: SMTP MTA Strict Transport Security for Ruby. Look up a recipient domain's
41
+ MTA-STS policy before delivering a message, to know whether TLS must be enforced
42
+ and which servers are legitimate.
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - CHANGELOG.md
48
+ - LICENSE
49
+ - README.md
50
+ - lib/mta_sts.rb
51
+ - lib/mta_sts/fetcher.rb
52
+ - lib/mta_sts/policy.rb
53
+ - lib/mta_sts/report.rb
54
+ - lib/mta_sts/report/result.rb
55
+ - lib/mta_sts/result.rb
56
+ - lib/mta_sts/version.rb
57
+ - mta_sts.gemspec
58
+ homepage: https://github.com/mailpiece/mta_sts
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ source_code_uri: https://github.com/mailpiece/mta_sts
63
+ changelog_uri: https://github.com/mailpiece/mta_sts/blob/main/CHANGELOG.md
64
+ bug_tracker_uri: https://github.com/mailpiece/mta_sts/issues
65
+ rubygems_mfa_required: 'true'
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '3.4'
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 4.0.10
81
+ specification_version: 4
82
+ summary: SMTP MTA Strict Transport Security for Ruby.
83
+ test_files: []