mta_sts 0.1.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: 0de9b2cdb6adccb9fb59489860cebd39ea5ccd9b31bafad218d05334bbbd79c5
4
+ data.tar.gz: 4d4390e9071edf2f2ca6a1decbd32a8a862f24572d042464241cc11abb226594
5
+ SHA512:
6
+ metadata.gz: 836678183d43c161fdff348dc1b6c466a485f071bfc2e6606b805631c89ddd6848dbe8f1e511219f6861a7e40df83c3197c1cacf70cca0f89062f9e4aa4cd12a
7
+ data.tar.gz: aeeadc31d4ccf52cbd5b54267050ea1fcb01b891e49a9295f7dbd19beff6ea64f064e6299ae3d11208c68c2ea30552b0e6be1eec05bb1b639fe7e9d49b938c6d
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ - MTA-STS lookup (RFC 8461): discover, fetch, parse, MX match
8
+ - `known:` refresh (ยง5.1) โ€” required keyword; no in-library cache
9
+ - Dual-axis `status` / `policy`; `"none"` is `no_record?`
10
+ - TLSRPT discovery (RFC 8460) โ€” `rua` only; no report submission
11
+ - dnsruby resolver (SERVFAIL โ‰  absence); DNSSEC and answer cache off
12
+ - CLI with exit 0 / 1 / 2 for answered / refused / unknown
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,274 @@
1
+ # mta_sts
2
+
3
+ ๐Ÿ”’ SMTP MTA Strict Transport Security for Ruby
4
+
5
+ Answers one question before a message goes out: **must this connection be encrypted, and to which servers?**
6
+
7
+ 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.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem "mta_sts"
15
+ ```
16
+
17
+ And run:
18
+
19
+ ```sh
20
+ bundle install
21
+ ```
22
+
23
+ Look up your first policy with:
24
+
25
+ ```ruby
26
+ require "mta_sts"
27
+
28
+ result = MtaSts.lookup("gmail.com", known: nil)
29
+ result.enforce? # => true
30
+ ```
31
+
32
+ ## How It Works
33
+
34
+ ### Looking Up a Policy
35
+
36
+ ```ruby
37
+ result = MtaSts.lookup(domain, known:, resolver:, fetcher:, now:)
38
+ # => #<MtaSts::Result status:, comment:, policy:>
39
+ ```
40
+
41
+ - **domain** - the recipient domain, the part after the `@`
42
+ - **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
43
+ - **resolver** - defaults to `MtaSts::Resolver.new`
44
+ - **fetcher** - defaults to `MtaSts::Fetcher.new`
45
+ - **now** - a `Time`, for expiry; defaults to `Time.now`
46
+
47
+ ```ruby
48
+ result = MtaSts.lookup("gmail.com", known: store["gmail.com"])
49
+ store["gmail.com"] = result.policy if result.policy
50
+
51
+ if result.enforce? && !result.allows?(mx_host)
52
+ # the policy names which servers are legitimate, and this is not one of them
53
+ end
54
+ ```
55
+
56
+ Hand back what you kept, keep what comes back - both halves matter.
57
+
58
+ ### Results
59
+
60
+ ```ruby
61
+ result.status # => "found"
62
+ result.policy.mode # => :enforce
63
+ result.policy.mx # => ["gmail-smtp-in.l.google.com", "*.gmail-smtp-in.l.google.com"]
64
+ result.policy.max_age # => 86400
65
+ result.policy.id # => "20190429T010101"
66
+ result.policy.domain # => "gmail.com"
67
+ result.policy.expires_at # => 2026-07-29 14:03:11 UTC
68
+
69
+ result.enforce? # => true
70
+ result.allows?("alt1.gmail-smtp-in.l.google.com") # => true
71
+ result.allows?("mx.attacker.example") # => false
72
+ ```
73
+
74
+ 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?`).
75
+
76
+ ### Status and Policy Are Separate Axes
77
+
78
+ `status` describes what this lookup learned. `policy` describes what you must honor. Nothing raises - a result always comes back with a `comment`:
79
+
80
+ ```ruby
81
+ result.status # => "temperror"
82
+ result.comment # => "DNS didn't answer for _mta-sts.example.com"
83
+ ```
84
+
85
+ Four statuses, each with a predicate:
86
+
87
+ - `found` / `found?` - a policy was fetched, or renewed against an unchanged `id`
88
+ - `none` / `no_record?` - no `_mta-sts` record seen
89
+ - `temperror` / `temperror?` - DNS or the policy host didn't answer
90
+ - `permerror` / `permerror?` - the record or policy file is broken
91
+
92
+ `policy` is present whenever there's one to honor: fresh, renewed, or the `known` one you passed in that hasn't expired.
93
+
94
+
95
+ | what happened | status | policy | `enforce?` |
96
+ | ----------------------------------------- | ----------- | ------- | ---------- |
97
+ | fresh fetch, `mode: enforce` | `found` | present | true |
98
+ | DNS timeout, held an enforcing policy | `temperror` | held | true |
99
+ | TXT record gone, held an enforcing policy | `none` | held | **true** |
100
+ | never had one, no TXT record | `none` | nil | false |
101
+
102
+
103
+ 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.
104
+
105
+ Use `enforce?` and `allows?` for the connection decision, not `status`:
106
+
107
+ ```ruby
108
+ if result.enforce? && !result.allows?(mx)
109
+ defer_or_fail
110
+ elsif result.temperror? && !result.policy
111
+ defer # we don't know; don't downgrade
112
+ end
113
+ ```
114
+
115
+ `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.
116
+
117
+ ### Holding On to a Policy
118
+
119
+ There's no cache in this library. `lookup` takes the policy you kept last time and hands one back.
120
+
121
+ ```ruby
122
+ result = MtaSts.lookup("example.com", known: store["example.com"])
123
+ store["example.com"] = result.policy if result.policy
124
+ ```
125
+
126
+ Skip that round trip and ยง5.1 doesn't apply. Three rules govern what comes back:
127
+
128
+ - A matching `id` renews the expiry without refetching
129
+ - A temporary failure keeps a policy that hasn't expired
130
+ - A fetched `mode: none` replaces it immediately
131
+
132
+ 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:
133
+
134
+ ```ruby
135
+ row = { body: policy.to_s, id: policy.id, domain: policy.domain, fetched_at: policy.fetched_at }
136
+ MtaSts::Policy.parse(row[:body], id: row[:id], domain: row[:domain], fetched_at: row[:fetched_at])
137
+ ```
138
+
139
+ ### TLSRPT Record Discovery
140
+
141
+ Where a domain wants TLS failure reports sent (RFC 8460). One TXT lookup at `_smtp._tls.<domain>` โ€” not report generation or submission.
142
+
143
+ ```ruby
144
+ result = MtaSts.reporting("gmail.com")
145
+ # => #<MtaSts::Report::Result status:, comment:, report:>
146
+
147
+ result.status # => "found"
148
+ result.rua # => ["mailto:tlsrpt@smtp.gmail.com"]
149
+ result.report.mailto
150
+ result.report.https
151
+ result.report.ignored # destinations that were named but are neither mailto: nor https:
152
+ ```
153
+
154
+ 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.
155
+
156
+ ### Custom Resolver & Fetcher
157
+
158
+ ```ruby
159
+ MtaSts.lookup("example.com", known: nil,
160
+ resolver: MyCachingResolver.new, # DNS, for discovery
161
+ fetcher: MyPolicyFetcher.new) # the HTTPS GET
162
+ ```
163
+
164
+ - **resolver** - needs one method, `txt`, over `dnsruby` by default. Covers discovery only (`_mta-sts` and `_smtp._tls`). Raise `NotFound` for no such record, `Timeout` or `ServerFailure` for no answer. The policy host itself is resolved by the HTTP stack inside the fetcher.
165
+ - **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.
166
+
167
+ ### Publishing a Policy
168
+
169
+ ```ruby
170
+ policy = MtaSts::Policy.new(mode: :enforce, mx: ["mx.example.com"], max_age: 604800)
171
+ puts policy.to_s
172
+ ```
173
+
174
+ ```
175
+ version: STSv1
176
+ mode: enforce
177
+ mx: mx.example.com
178
+ max_age: 604800
179
+ ```
180
+
181
+ And read one back:
182
+
183
+ ```ruby
184
+ MtaSts::Policy.parse(File.read("mta-sts.txt"))
185
+ # => #<MtaSts::Policy mode: :enforce, mx: ["mx.example.com"], max_age: 604800>
186
+ ```
187
+
188
+ ## Command Line
189
+
190
+ ```sh
191
+ mta-sts gmail.com alt1.gmail-smtp-in.l.google.com
192
+ ```
193
+
194
+ ```
195
+ domain: gmail.com
196
+ status: found
197
+ comment: fetched policy id 20190429T010101 from mta-sts.gmail.com
198
+ mode: enforce
199
+ id: 20190429T010101
200
+ max_age: 86400
201
+ expires: 2026-07-29 15:51:41 UTC
202
+ mx:
203
+ smtp.google.com
204
+ gmail-smtp-in.l.google.com
205
+ *.gmail-smtp-in.l.google.com
206
+ rua:
207
+ mailto:tlsrpt@smtp.gmail.com
208
+
209
+ alt1.gmail-smtp-in.l.google.com: accepted
210
+ ```
211
+
212
+ Exit codes answer the MTA-STS question only. TLSRPT is printed for diagnostics and never changes the exit:
213
+
214
+ - `0` - answered, a policy vouches for the MX, or the domain publishes none
215
+ - `1` - refused, a policy applies and does not name it
216
+ - `2` - unknown, DNS or the policy host didn't answer, or what it served was broken
217
+ - `64` - usage
218
+
219
+ ## What It Is Strict About
220
+
221
+ - HTTP 3xx are never followed
222
+ - HTTP caching (RFC 7234) is never used - freshness comes from the TXT `id` and `max_age` only
223
+ - Only `200` is a policy
224
+ - The certificate is verified against `mta-sts.<domain>`, the policy host name
225
+ - The `_mta-sts` discovery record must match the ABNF (`v=STSv1;` case-sensitive, a single well-formed `id`)
226
+ - Fetch body size and total time are capped
227
+
228
+ ## What It Is Not That Strict
229
+
230
+ - `Content-Type: text/plain` is checked but not enforced - ยง3.2 says SHOULD, not MUST, and real policies in the wild omit it
231
+ - Line terminators may be bare `LF` as well as `CRLF`
232
+
233
+ ## What It Does Not Check
234
+
235
+ - **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.
236
+ - **The address the policy host resolves to.** Fetched at whatever address DNS returns. The certificate check is what protects you here.
237
+ - **DANE.** A separate mechanism needing DNSSEC validation and the peer certificate mid-handshake, which this library never sees.
238
+ - **TLSRPT report generation or submission.** This library finds the `rua`; building and sending aggregate reports is the application's job.
239
+ - **Anything about the SMTP connection.** This library opens one socket, to fetch a policy over HTTPS. It never speaks SMTP.
240
+
241
+ ## Testing
242
+
243
+ ```sh
244
+ bundle install
245
+ rake
246
+ ```
247
+
248
+ Nothing 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.
249
+
250
+ ## History
251
+
252
+ View the [changelog](https://github.com/mailpiece/mta_sts/blob/main/CHANGELOG.md)
253
+
254
+ ## Contributing
255
+
256
+ Everyone is encouraged to help improve this project. Here are a few ways you can help:
257
+
258
+ - [Report bugs](https://github.com/mailpiece/mta_sts/issues)
259
+ - Fix bugs and [submit pull requests](https://github.com/mailpiece/mta_sts/pulls)
260
+ - Write, clarify, or fix documentation
261
+ - Suggest or add new features
262
+
263
+ To get started with development:
264
+
265
+ ```sh
266
+ git clone https://github.com/mailpiece/mta_sts.git
267
+ cd mta_sts
268
+ bundle install
269
+ bundle exec rakecl
270
+ ```
271
+
272
+ ## License
273
+
274
+ MIT. See [LICENSE](LICENSE).
data/exe/mta-sts ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "mta_sts"
4
+
5
+ exit MtaSts::Cli.new(ARGV).run
@@ -0,0 +1,113 @@
1
+ module MtaSts
2
+ # Exit: 0 answered, 1 refused, 2 unknown, 64 usage
3
+ class Cli
4
+ ANSWERED = 0
5
+ REFUSED = 1
6
+ UNKNOWN = 2
7
+ USAGE_ERROR = 64
8
+
9
+ USAGE = <<~TEXT.freeze
10
+ usage: mta-sts <domain> [mx-hostname]
11
+
12
+ exit: #{ANSWERED} answered, #{REFUSED} refused, #{UNKNOWN} unknown, #{USAGE_ERROR} usage
13
+ TEXT
14
+
15
+ def initialize(argv, resolver: Resolver.new, fetcher: Fetcher.new, out: $stdout, err: $stderr)
16
+ @domain, @mx_host, @extra = argv
17
+ @resolver, @fetcher = resolver, fetcher
18
+ @out, @err = out, err
19
+ end
20
+
21
+ def run
22
+ if usable_arguments?
23
+ answer
24
+ else
25
+ @err.puts USAGE
26
+ USAGE_ERROR
27
+ end
28
+ end
29
+
30
+ private
31
+ def usable_arguments?
32
+ !(@domain.nil? || @domain.empty? || @domain.start_with?("-") || @extra)
33
+ end
34
+
35
+ def answer
36
+ result = MtaSts.lookup(@domain, known: nil, resolver: @resolver, fetcher: @fetcher)
37
+
38
+ describe(result)
39
+ describe_reporting
40
+
41
+ if @mx_host
42
+ judge(result)
43
+ elsif answerable?(result)
44
+ ANSWERED
45
+ else
46
+ UNKNOWN
47
+ end
48
+ end
49
+
50
+ def describe(result)
51
+ @out.puts "domain: #{@domain}"
52
+ @out.puts "status: #{result.status}"
53
+ @out.puts "comment: #{result.comment}" if result.comment
54
+
55
+ if policy = result.policy
56
+ @out.puts "mode: #{policy.mode}"
57
+ @out.puts "id: #{policy.id}"
58
+ @out.puts "max_age: #{policy.max_age}"
59
+ @out.puts "expires: #{policy.expires_at.utc}"
60
+ @out.puts "mx:"
61
+ policy.mx.each { |pattern| @out.puts " #{pattern}" }
62
+ end
63
+
64
+ if result.no_record? && result.policy.nil?
65
+ @out.puts "note: holds no earlier policy, so this is only what DNS says now"
66
+ end
67
+ end
68
+
69
+ def describe_reporting
70
+ result = MtaSts.reporting(@domain, resolver: @resolver)
71
+
72
+ if result.found?
73
+ describe_destinations(result.report)
74
+ else
75
+ @out.puts "rua: #{result.comment}"
76
+ end
77
+ end
78
+
79
+ def describe_destinations(report)
80
+ @out.puts "rua:"
81
+ report.rua.each { |destination| @out.puts " #{destination}" }
82
+
83
+ describe_ignored(report)
84
+ end
85
+
86
+ def describe_ignored(report)
87
+ if report.ignored.any?
88
+ @out.puts "ignored: named in rua but neither mailto: nor https:, so no report goes there"
89
+ report.ignored.each { |destination| @out.puts " #{destination}" }
90
+ end
91
+ end
92
+
93
+ def answerable?(result)
94
+ !result.policy.nil? || result.no_record?
95
+ end
96
+
97
+ def judge(result)
98
+ @out.puts
99
+
100
+ case
101
+ when !answerable?(result)
102
+ @out.puts "#{@mx_host}: unknown โ€” no policy could be read, so this can't be answered"
103
+ UNKNOWN
104
+ when result.allows?(@mx_host)
105
+ @out.puts "#{@mx_host}: accepted"
106
+ ANSWERED
107
+ else
108
+ @out.puts "#{@mx_host}: refused โ€” this policy does not name it"
109
+ REFUSED
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,123 @@
1
+ require "net/http"
2
+ require "openssl"
3
+ require "zlib"
4
+ require "mta_sts/version"
5
+
6
+ module MtaSts
7
+ # HTTPS GET of mta-sts.<domain>/.well-known/mta-sts.txt (RFC 8461 ยง3.3)
8
+ class Fetcher
9
+ class Error < StandardError; end
10
+ class HttpError < Error; end
11
+ class CertificateError < Error; end
12
+ class Timeout < Error; end
13
+
14
+ WELL_KNOWN_PATH = "/.well-known/mta-sts.txt"
15
+ PORT = 443
16
+ OPEN_TIMEOUT = 10
17
+ READ_TIMEOUT = 10
18
+ # read_timeout renews per read; this one does not โ€” stops a slow-drip body
19
+ TOTAL_TIMEOUT = 30
20
+ MAX_BODY_BYTES = 64 * 1024
21
+ USER_AGENT = "mta_sts/#{VERSION} (+https://github.com/mailpiece/mta_sts)"
22
+
23
+ # RFC 8461 ยง3.2 SHOULD text/plain โ€” reported, not enforced
24
+ class Body < String
25
+ attr_reader :content_type
26
+
27
+ def initialize(body, content_type: nil)
28
+ super(body)
29
+ @content_type = content_type
30
+ end
31
+
32
+ def text_plain?
33
+ content_type.to_s.split(";").first.to_s.strip.casecmp?("text/plain")
34
+ end
35
+ end
36
+
37
+ def initialize(open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT,
38
+ total_timeout: TOTAL_TIMEOUT, max_body_bytes: MAX_BODY_BYTES)
39
+ @open_timeout = open_timeout
40
+ @read_timeout = read_timeout
41
+ @total_timeout = total_timeout
42
+ @max_body_bytes = max_body_bytes
43
+ end
44
+
45
+ def fetch(host)
46
+ get(normalize(host))
47
+ end
48
+
49
+ private
50
+ def normalize(host)
51
+ host.to_s.downcase.chomp(".")
52
+ end
53
+
54
+ def get(host)
55
+ deadline = monotonic_now + @total_timeout
56
+
57
+ with_response(host) do |response|
58
+ if response.is_a?(Net::HTTPOK)
59
+ body_from(response, host, deadline)
60
+ else
61
+ # RFC 8461 ยง3.3 MUST NOT follow redirects
62
+ raise HttpError, "#{host} answered #{response.code}"
63
+ end
64
+ end
65
+ end
66
+
67
+ def with_response(host)
68
+ result = nil
69
+
70
+ connection(host).start do |http|
71
+ http.request(policy_request) { |response| result = yield response }
72
+ end
73
+
74
+ result
75
+ rescue OpenSSL::SSL::SSLError => error
76
+ raise CertificateError, "#{host} presented a certificate that didn't verify: #{error.message}"
77
+ rescue Net::OpenTimeout, Net::ReadTimeout, ::Timeout::Error
78
+ raise Timeout, "timed out fetching the policy from #{host}"
79
+ rescue Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error => error
80
+ raise HttpError, "#{host} answered with malformed HTTP: #{error.message}"
81
+ rescue SystemCallError, SocketError, IOError, EOFError => error
82
+ raise Error, "couldn't reach #{host}: #{error.message}"
83
+ end
84
+
85
+ def connection(host)
86
+ Net::HTTP.new(host, PORT).tap do |http|
87
+ http.use_ssl = true
88
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
89
+ http.verify_hostname = true
90
+ http.min_version = OpenSSL::SSL::TLS1_2_VERSION
91
+ http.open_timeout = @open_timeout
92
+ http.read_timeout = @read_timeout
93
+ end
94
+ end
95
+
96
+ # RFC 8461 ยง3.3 โ€” no HTTP caching; freshness is TXT id + max_age
97
+ def policy_request
98
+ Net::HTTP::Get.new(WELL_KNOWN_PATH, "accept" => "text/plain", "user-agent" => USER_AGENT)
99
+ end
100
+
101
+ def body_from(response, host, deadline)
102
+ body = +""
103
+
104
+ response.read_body do |chunk|
105
+ body << chunk
106
+
107
+ if body.bytesize > @max_body_bytes
108
+ raise HttpError, "#{host} sent more than #{@max_body_bytes} bytes of policy"
109
+ end
110
+
111
+ if monotonic_now > deadline
112
+ raise Timeout, "#{host} took longer than #{@total_timeout} seconds to send the policy"
113
+ end
114
+ end
115
+
116
+ Body.new(body.force_encoding(Encoding::UTF_8), content_type: response["content-type"])
117
+ end
118
+
119
+ def monotonic_now
120
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,184 @@
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
+ def scrubbed(name)
55
+ name = name.to_s
56
+ name.valid_encoding? ? name : name.scrub
57
+ end
58
+
59
+ def ascii(name)
60
+ name.ascii_only? ? name : SimpleIDN.to_ascii(name)
61
+ rescue StandardError
62
+ name
63
+ end
64
+
65
+ def single(fields, name)
66
+ values = fields.fetch(name, [])
67
+
68
+ if values.size > 1
69
+ raise Invalid, "#{name} appears #{values.size} times; it may appear once"
70
+ else
71
+ values.first
72
+ end
73
+ end
74
+
75
+ def validate_version(version)
76
+ unless version&.casecmp?(POLICY_VERSION)
77
+ raise Invalid, "version must be #{POLICY_VERSION}, not #{version.inspect}"
78
+ end
79
+ end
80
+ end
81
+
82
+ attr_reader :mode, :mx, :max_age, :id, :domain, :fetched_at
83
+
84
+ def initialize(mode:, max_age:, mx: [], id: nil, domain: nil, fetched_at: Time.now)
85
+ @mode = validated_mode(mode)
86
+ @mx = validated_mx(mx)
87
+ @max_age = validated_max_age(max_age)
88
+ @domain = domain && self.class.normalize(domain)
89
+ @id, @fetched_at = id, fetched_at
90
+ end
91
+
92
+ def none?
93
+ mode == :none
94
+ end
95
+
96
+ def testing?
97
+ mode == :testing
98
+ end
99
+
100
+ def enforce?
101
+ mode == :enforce
102
+ end
103
+
104
+ def allows?(host)
105
+ if none?
106
+ true
107
+ else
108
+ host = self.class.normalize(host)
109
+
110
+ mx.any? { |pattern| matches?(pattern, host) }
111
+ end
112
+ end
113
+
114
+ def expires_at
115
+ fetched_at + max_age
116
+ end
117
+
118
+ def expired?(now = Time.now)
119
+ now >= expires_at
120
+ end
121
+
122
+ def to_s
123
+ lines = [ "version: #{POLICY_VERSION}", "mode: #{mode}" ]
124
+ lines.concat mx.map { |pattern| "mx: #{pattern}" }
125
+ lines << "max_age: #{max_age}"
126
+
127
+ lines.map { |line| "#{line}\r\n" }.join
128
+ end
129
+
130
+ def inspect
131
+ "#<#{self.class.name} mode: #{mode.inspect}, mx: #{mx.inspect}, max_age: #{max_age.inspect}>"
132
+ end
133
+
134
+ private
135
+ def validated_mode(mode)
136
+ if known = MODES.find { |candidate| candidate.to_s == mode.to_s.strip.downcase }
137
+ known
138
+ else
139
+ raise Invalid, "mode must be one of #{MODES.join(", ")}, not #{mode.inspect}"
140
+ end
141
+ end
142
+
143
+ # RFC 8461 ยง3.2 โ€” at least one mx unless mode is none
144
+ def validated_mx(mx)
145
+ patterns = Array(mx).map { |pattern| validated_pattern(pattern) }.uniq
146
+
147
+ if patterns.empty? && !none?
148
+ raise Invalid, "mx must name at least one host unless mode is none"
149
+ else
150
+ patterns
151
+ end
152
+ end
153
+
154
+ def validated_pattern(pattern)
155
+ normalized = self.class.normalize(pattern)
156
+
157
+ if normalized.delete("*.").empty?
158
+ raise Invalid, "mx must name a host, not #{pattern.inspect}"
159
+ else
160
+ normalized
161
+ end
162
+ end
163
+
164
+ def validated_max_age(max_age)
165
+ # grammar is 1*DIGIT โ€” not Integer(), which accepts 0x10 / 1_0
166
+ if max_age.to_s.match?(DIGITS) && MAX_AGE.cover?(seconds = max_age.to_i)
167
+ seconds
168
+ else
169
+ raise Invalid, "max_age must be a whole number of seconds in #{MAX_AGE}, not #{max_age.inspect}"
170
+ end
171
+ end
172
+
173
+ # RFC 8461 ยง4.1 โ€” "*." covers exactly one label
174
+ def matches?(pattern, host)
175
+ if pattern.start_with?(WILDCARD)
176
+ label, _, parent = host.partition(".")
177
+
178
+ !label.empty? && parent == pattern.delete_prefix(WILDCARD)
179
+ else
180
+ pattern == host
181
+ end
182
+ end
183
+ end
184
+ 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,104 @@
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: tlsrpt-version = %s"v=TLSRPTv1", then 1*(field-delim tlsrpt-field)
10
+ REPORT_RECORD = /\Av=#{REPORT_VERSION};/
11
+ FIELD_DELIM = ";"
12
+ RUA_FIELD = "rua=" # RFC 8460 ยง3
13
+ URI_DELIM = ","
14
+ MAILTO = /\Amailto:/i # RFC 3986 ยง3.1 โ€” scheme is case-insensitive
15
+ HTTPS = /\Ahttps:/i
16
+ SCHEMES = [ MAILTO, HTTPS ].freeze
17
+
18
+ class << self
19
+ def parse(text, domain: nil)
20
+ new rua: uris_in(fields_in(text)), domain: domain
21
+ end
22
+
23
+ private
24
+ def fields_in(text)
25
+ fields = validated_record(text).split(FIELD_DELIM).drop(1).map(&:strip)
26
+
27
+ if fields.empty?
28
+ raise Invalid, "record must carry at least one field after v=#{REPORT_VERSION}"
29
+ else
30
+ fields
31
+ end
32
+ end
33
+
34
+ def validated_record(text)
35
+ text = validated_encoding(text)
36
+
37
+ if text.match?(REPORT_RECORD)
38
+ text
39
+ else
40
+ raise Invalid, "record must begin with v=#{REPORT_VERSION};, not #{text.inspect}"
41
+ end
42
+ end
43
+
44
+ def validated_encoding(text)
45
+ text = text.to_s
46
+
47
+ if text.valid_encoding?
48
+ text
49
+ else
50
+ raise Invalid, "record is not valid #{text.encoding}"
51
+ end
52
+ end
53
+
54
+ # RFC 8460 ยง3 โ€” unknown fields ignored; multiple rua fields allowed
55
+ def uris_in(fields)
56
+ rua_fields = fields.select { |field| field.start_with?(RUA_FIELD) }
57
+
58
+ rua_fields.flat_map { |field| destinations_in(field.delete_prefix(RUA_FIELD)) }
59
+ end
60
+
61
+ def destinations_in(value)
62
+ value.split(URI_DELIM, -1).map(&:strip)
63
+ end
64
+ end
65
+
66
+ attr_reader :rua, :ignored, :domain
67
+
68
+ def initialize(rua:, domain: nil)
69
+ @rua, @ignored = validated_destinations(rua)
70
+ @domain = domain && Policy.normalize(domain)
71
+ end
72
+
73
+ def mailto
74
+ rua.grep(MAILTO)
75
+ end
76
+
77
+ def https
78
+ rua.grep(HTTPS)
79
+ end
80
+
81
+ def to_s
82
+ "v=#{REPORT_VERSION}; #{RUA_FIELD}#{rua.join(", ")}"
83
+ end
84
+
85
+ def inspect
86
+ "#<#{self.class.name} rua: #{rua.inspect}, ignored: #{ignored.inspect}>"
87
+ end
88
+
89
+ private
90
+ def validated_destinations(uris)
91
+ usable, unusable = Array(uris).partition { |uri| supported?(uri) }
92
+
93
+ if usable.empty?
94
+ raise Invalid, "rua must name at least one mailto: or https: destination"
95
+ else
96
+ [ usable, unusable ]
97
+ end
98
+ end
99
+
100
+ def supported?(uri)
101
+ SCHEMES.any? { |scheme| uri.to_s.match?(scheme) }
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,74 @@
1
+ require "dnsruby"
2
+
3
+ module MtaSts
4
+ class Resolver
5
+ class Error < StandardError; end
6
+ class NotFound < Error; end # NXDOMAIN / NODATA
7
+ class Timeout < Error; end
8
+ class ServerFailure < Error; end # SERVFAIL, REFUSED, transport failure
9
+
10
+ QUERY_TIMEOUT = 5
11
+
12
+ def initialize(timeout: QUERY_TIMEOUT)
13
+ @timeout = timeout
14
+ end
15
+
16
+ def txt(name)
17
+ records(name).map { |record| record.strings.join }
18
+ end
19
+
20
+ private
21
+ def records(name)
22
+ answer = resolver.query(name.to_s, "TXT").answer.grep(Dnsruby::RR::IN::TXT)
23
+
24
+ if answer.any?
25
+ answer
26
+ else
27
+ raise NotFound, "no TXT records for #{name}"
28
+ end
29
+ rescue Dnsruby::NXDomain
30
+ raise NotFound, "no such name #{name}"
31
+ rescue Dnsruby::ResolvTimeout
32
+ raise Timeout, "timed out resolving TXT #{name}"
33
+ # Fall through to ServerFailure, never NotFound โ€” "couldn't ask" must not become "no policy"
34
+ rescue Dnsruby::ResolvError, IOError, SocketError, SystemCallError => error
35
+ raise ServerFailure, "couldn't resolve TXT #{name}: #{reason(error)}"
36
+ end
37
+
38
+ def reason(error)
39
+ if error.is_a?(Dnsruby::ResolvError)
40
+ rcode(error)
41
+ else
42
+ without_backtrace(error.message)
43
+ end
44
+ end
45
+
46
+ def rcode(error)
47
+ name = error.class.name&.split("::")&.last
48
+
49
+ case
50
+ when name.nil? then error.message
51
+ when error.message == error.class.name then name
52
+ else "#{name}, #{error.message}"
53
+ end
54
+ end
55
+
56
+ def without_backtrace(message)
57
+ message.sub(/\s*\[".*\z/m, "")
58
+ end
59
+
60
+ # DNSSEC off: MTA-STS trusts the policy-host certificate, not DNS.
61
+ # Caching off: dnsruby's cache is process-global and would hide SERVFAIL behind a stale hit.
62
+ def resolver
63
+ @resolver ||= Dnsruby::Resolver.new(**config).tap do |dns|
64
+ dns.dnssec = false
65
+ dns.do_caching = false
66
+ dns.query_timeout = @timeout
67
+ end
68
+ end
69
+
70
+ def config
71
+ {}
72
+ end
73
+ end
74
+ 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.1.0"
3
+ end
data/lib/mta_sts.rb ADDED
@@ -0,0 +1,153 @@
1
+ require "mta_sts/version"
2
+ require "mta_sts/resolver"
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
+ require "mta_sts/cli"
9
+
10
+ module MtaSts
11
+ DISCOVERY_PREFIX = "_mta-sts."
12
+ POLICY_HOST_PREFIX = "mta-sts."
13
+ REPORTING_PREFIX = "_smtp._tls." # RFC 8460 ยง3
14
+
15
+ # RFC 8461 ยง3.1 ABNF: sts-version = %s"v=STSv1", then sts-field-delim
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: 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: 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 Resolver::NotFound
78
+ :absent
79
+ rescue Resolver::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,37 @@
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.3.4"
9
+ spec.authors = [ "PostRider" ]
10
+ spec.email = [ "support@postrider.dev" ]
11
+
12
+ spec.summary = "MTA-STS policy discovery and enforcement for outbound SMTP in Ruby."
13
+ spec.description = "Answers one question before delivering a message: must this connection be encrypted, and to which servers?"
14
+
15
+ spec.homepage = "https://github.com/mailpiece/mta_sts"
16
+ spec.license = "MIT"
17
+
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ spec.files = Dir[
24
+ "lib/**/*.rb",
25
+ "exe/**/*",
26
+ "CHANGELOG.md",
27
+ "README.md",
28
+ "LICENSE",
29
+ "mta_sts.gemspec"
30
+ ]
31
+ spec.bindir = "exe"
32
+ spec.executables = [ "mta-sts" ]
33
+ spec.require_paths = [ "lib" ]
34
+
35
+ spec.add_dependency "dnsruby", "~> 1.74"
36
+ spec.add_dependency "simpleidn", "~> 0.2"
37
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mta_sts
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - PostRider
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dnsruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.74'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.74'
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: 'Answers one question before delivering a message: must this connection
41
+ be encrypted, and to which servers?'
42
+ email:
43
+ - support@postrider.dev
44
+ executables:
45
+ - mta-sts
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE
51
+ - README.md
52
+ - exe/mta-sts
53
+ - lib/mta_sts.rb
54
+ - lib/mta_sts/cli.rb
55
+ - lib/mta_sts/fetcher.rb
56
+ - lib/mta_sts/policy.rb
57
+ - lib/mta_sts/report.rb
58
+ - lib/mta_sts/report/result.rb
59
+ - lib/mta_sts/resolver.rb
60
+ - lib/mta_sts/result.rb
61
+ - lib/mta_sts/version.rb
62
+ - mta_sts.gemspec
63
+ homepage: https://github.com/mailpiece/mta_sts
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ source_code_uri: https://github.com/mailpiece/mta_sts
68
+ changelog_uri: https://github.com/mailpiece/mta_sts/blob/main/CHANGELOG.md
69
+ bug_tracker_uri: https://github.com/mailpiece/mta_sts/issues
70
+ rubygems_mfa_required: 'true'
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 3.3.4
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 4.0.3
86
+ specification_version: 4
87
+ summary: MTA-STS policy discovery and enforcement for outbound SMTP in Ruby.
88
+ test_files: []