mailertogo-spf 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: 1a2e188c35f296a679f4be3be3e89c2a6c15a3b1bedd4a9f3c8f2fe0bd8480cf
4
+ data.tar.gz: '0359e4412f1c81bf20e71b99a088dcd60b58b6125abadae058a2f2990a808296'
5
+ SHA512:
6
+ metadata.gz: '038c312b2ad13a04b55953d61d129a9fd928dd09d4a6eaf38ace13f7b1bab26cfe963b9e04002480ad337e26e4ff1114625860de19775d9c19a023a1c3f7c320'
7
+ data.tar.gz: 9eecc4f061f6242edd06fc5fa083b95b16023047602d9515ab449121b718445b0e4222be3231fbfcb9e4c9f46bd5c059c0efd9d447435abba6b0b4d3b3db62eb
data/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-08-16
8
+
9
+ First release. Extracted from the SPF engine MailerToGo runs behind its own
10
+ domain setup and monitoring.
11
+
12
+ ### Added
13
+
14
+ - `MailerToGo::SPF.authorize` — resolves a domain's SPF the way a receiving MTA
15
+ does: follows `include:` and the `redirect=` modifier, stops at the first
16
+ matching mechanism (RFC 7208 §4.6.2), and counts DNS-querying terms against
17
+ the §4.6.4 cap of 10. Returns a five-valued `Result`
18
+ (`:pass` / `:pinned` / `:fail` / `:permerror` / `:unknown`) with predicates,
19
+ the matched name, the lookups a receiver spends, and the terminal `all`
20
+ qualifier (§5.1) as a separate field.
21
+ - `:pinned` — its own status for a record that hardcodes the sender's addresses
22
+ instead of including it: passes SPF today, breaks silently the day an address
23
+ moves.
24
+ - `MailerToGo::SPF.merge_plan` — given what is already published at a name,
25
+ returns a `Plan` (`:publish` / `:merge` / `:deduplicate` / `:satisfied`) and a
26
+ single merged record that preserves the domain's own `all` qualifier, puts the
27
+ new include last, drops terms glued onto or stranded after `all` (with notes),
28
+ and is **withheld** when merging would push the record past the lookup cap.
29
+ - Injectable DNS: any object responding to `#call(name)`, with a stdlib
30
+ `Resolv::DNS` default and an optional `CachingResolver`. No runtime
31
+ dependencies, no Rails.
32
+
33
+ [0.1.0]: https://github.com/aluminumio/mailertogo-spf/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MailerToGo
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,295 @@
1
+ # mailertogo-spf
2
+
3
+ Read a domain's SPF record the way a receiving mail server does, and work out
4
+ what it should publish.
5
+
6
+ SPF looks like a string and is actually a tree. The obvious check —
7
+ "does this domain's TXT record contain `include:_spf.mailertogo.net`?" — is
8
+ wrong about a large slice of the real internet, in both directions:
9
+
10
+ ```
11
+ example.com TXT v=spf1 include:spf.hosting.example include:mailertogo.net ~all
12
+ mailertogo.net TXT v=spf1 include:_spf.mailertogo.net ~all
13
+ _spf.mailertogo.net TXT v=spf1 ip4:… ip4:… ~all
14
+ ```
15
+
16
+ That domain is authorized. Every receiver agrees. A literal token match says it
17
+ is not — and if you gate anything on that answer (verification state, drift
18
+ alerts, the ability to send) you have just broken a customer who did nothing
19
+ wrong.
20
+
21
+ This gem resolves the chain instead: it follows `include:` and `redirect=`,
22
+ stops at the first mechanism that matches (RFC 7208 §4.6.2), and counts
23
+ DNS-querying terms against §4.6.4's cap of 10 — the same arithmetic a receiver
24
+ does, so a record this gem passes is a record that passes in the wild.
25
+
26
+ No Rails. No runtime dependencies. DNS goes through an injectable resolver, so
27
+ your test suite never touches the network.
28
+
29
+ ## Install
30
+
31
+ ```ruby
32
+ gem "mailertogo-spf"
33
+ ```
34
+
35
+ ```console
36
+ $ gem install mailertogo-spf
37
+ ```
38
+
39
+ ## Is this domain authorized?
40
+
41
+ ```ruby
42
+ require "mailertogo/spf"
43
+
44
+ result = MailerToGo::SPF.authorize("example.com")
45
+
46
+ result.pass? # => true
47
+ result.matched # => "mailertogo.net" (the name in their record that meant us)
48
+ result.lookups # => 3 (DNS-querying terms a receiver spends)
49
+ result.detail # => "SPF reaches mailertogo.net through its include chain (3 DNS lookups)"
50
+ ```
51
+
52
+ Asking about a different sender is one keyword:
53
+
54
+ ```ruby
55
+ MailerToGo::SPF.authorize("example.com", include: "spf.example.net")
56
+
57
+ # …or, if you publish an outer alias as well as a leaf, name both:
58
+ MailerToGo::SPF.authorize("example.com",
59
+ include: "spf.example.net",
60
+ aliases: ["example.net"])
61
+ ```
62
+
63
+ ### The five statuses
64
+
65
+ | `status` | What happened | Is it a failure? |
66
+ |--------------|---------------|------------------|
67
+ | `:pass` | The include is reachable from the record. Durable authorization. | no |
68
+ | `:pinned` | No include chain to you, but the record hardcodes your current sending IPs. | not yet — see below |
69
+ | `:fail` | The chain resolved fine. You are simply not in it. | yes |
70
+ | `:permerror` | The record is broken: two `v=spf1` records (§4.5), or past the §4.6.4 lookup cap. Receivers reject it, so nothing passes. | yes |
71
+ | `:unknown` | DNS did not answer. Verdict withheld. | **no** |
72
+
73
+ `:unknown` is the important one. A resolver timeout must never be reported as
74
+ "this domain removed my record" — that is how a monitoring job un-verifies a
75
+ hundred healthy domains during someone else's outage. `failed?` is true for
76
+ `:fail` and `:permerror` only.
77
+
78
+ `:pinned` is the other one worth knowing about. A customer who copies your IP
79
+ addresses into their record instead of including you authenticates *today* and
80
+ breaks silently the day you move an address — and keeps authorizing that
81
+ address after you release it to somebody else. It passes SPF, so a naive
82
+ checker calls it a pass; it is a defect, so this gem gives it its own status.
83
+
84
+ ```ruby
85
+ result.pinned? # => true
86
+ result.reason # => :pinned_partial (they cover only some of your ranges)
87
+ result.defect # => :ip_pinned (:lookup_limit / :duplicate_records for permerrors)
88
+ ```
89
+
90
+ ### `-all` versus `~all`
91
+
92
+ The qualifier on the terminal `all` (§5.1) is what receivers do with mail the
93
+ record does *not* authorize: `-all` says reject, `~all` says mark. It rides on
94
+ the result as a field rather than a status, because it does not change the
95
+ yes/no answer — but it changes how urgently you should tell someone, and what
96
+ you tell them:
97
+
98
+ ```ruby
99
+ result.all_qualifier # => :fail | :softfail | :neutral | :pass | nil
100
+ result.softfail? # => true when unauthorized under a ~all
101
+ ```
102
+
103
+ ### Other things on `Result`
104
+
105
+ ```ruby
106
+ result.permerror_with_sender_published? # their record is broken, but your include IS in it —
107
+ # a different problem with a different remedy
108
+ result.partial? # part of the chain didn't resolve, so `lookups` is a floor
109
+ ```
110
+
111
+ ## What should they publish?
112
+
113
+ The instruction "add a TXT record: `v=spf1 include:_spf.mailertogo.net ~all`" is
114
+ correct only for a domain with no SPF at all. Give it to a domain that already
115
+ has SPF — Google Workspace, a registrar default, Microsoft 365, another ESP —
116
+ and a conscientious customer will follow it exactly and end up with **two**
117
+ `v=spf1` records. RFC 7208 §3.2 forbids that, §4.5 makes it a permerror, and
118
+ the result is worse than doing nothing: it breaks SPF for every sender they
119
+ had, not just yours.
120
+
121
+ `merge_plan` resolves what is published and hands back the instruction that is
122
+ actually correct for that domain:
123
+
124
+ ```ruby
125
+ plan = MailerToGo::SPF.merge_plan("example.com")
126
+
127
+ plan.action # => :publish | :merge | :deduplicate | :satisfied
128
+ plan.replacement? # => true — this is a REPLACE, not an ADD
129
+ plan.offered_record # => "v=spf1 include:_spf.google.com include:_spf.mailertogo.net ~all"
130
+ plan.severity # => :info | :warning
131
+ plan.notes # => human-readable notes about anything dropped
132
+ ```
133
+
134
+ | `action` | Meaning |
135
+ |----------|---------|
136
+ | `:publish` | Nothing there: hand them the standalone record. Also the fallback when DNS did not answer. |
137
+ | `:merge` | One record exists and does not authorize you: replace it with `offered_record`. |
138
+ | `:deduplicate` | Two or more `v=spf1` records are already published: replace them all with one. |
139
+ | `:satisfied` | A single record already reaches you. Say nothing; never rewrite a working record. |
140
+
141
+ The merge is careful about three things, because each is a way to make a
142
+ customer's mail worse rather than better:
143
+
144
+ - **Their `all` qualifier is their policy.** It is carried across verbatim.
145
+ Quietly rewriting `-all` to `~all` would relax how receivers treat *every*
146
+ sender they have.
147
+ - **The new include goes last**, immediately before the terminal `all`, because
148
+ a mechanism after `all` is never evaluated (§5.1/§6.1). Terms that were
149
+ already stranded there are dropped, with a note — keeping them would newly
150
+ authorize a sender that receivers ignore today. Modifiers (`redirect=`,
151
+ `exp=`) are position-independent (§4.6.1) and survive.
152
+ - **Merging costs a DNS lookup**, and §4.6.4 caps an evaluation at 10. A record
153
+ already near the cap can be pushed over it, and a record over the cap
154
+ permerrors for everyone. The merged line is measured, and **withheld** if it
155
+ would not fit:
156
+
157
+ ```ruby
158
+ plan.over_limit? # => true
159
+ plan.lookups # => 11
160
+ plan.lookup_limit # => 10
161
+ plan.offered_record # => nil — we will not hand over a line we know breaks on arrival
162
+ plan.merged_record # => still computed, if you want to show it as a diagnosis
163
+ ```
164
+
165
+ `Plan` is a null-object away from nil checks — `MailerToGo::SPF::Plan.none`
166
+ answers every question as "no instruction" — and it can reconcile a row in a
167
+ "publish these records" table for you, so the decision to withhold lives in one
168
+ place:
169
+
170
+ ```ruby
171
+ plan.replaces?(name: "example.com", value: "v=spf1 include:_spf.mailertogo.net ~all") # => true
172
+ plan.value_for(name: "example.com", value: "v=spf1 include:_spf.mailertogo.net ~all")
173
+ # => "v=spf1 include:_spf.google.com include:_spf.mailertogo.net ~all"
174
+ ```
175
+
176
+ ## DNS
177
+
178
+ A resolver is anything that responds to `#call(name)` and returns:
179
+
180
+ | Return | Meaning |
181
+ |--------|---------|
182
+ | `["v=spf1 …"]` | the TXT strings at that name |
183
+ | `[]` | the name publishes no TXT (or does not exist): a definitive "nothing here" |
184
+ | `nil` | DNS did not answer: **inconclusive**, and the verdict is withheld |
185
+
186
+ That three-way return is the whole contract, and the `nil` is the load-bearing
187
+ part. The default resolver uses Ruby's stdlib `Resolv::DNS`, which is why this
188
+ gem has no runtime dependencies:
189
+
190
+ ```ruby
191
+ MailerToGo::SPF::Resolver.new(timeout: 3, nameservers: %w[1.1.1.1 8.8.8.8])
192
+ ```
193
+
194
+ Resolving a chain is up to ten serial round-trips, which is not something to do
195
+ twice in a request path, so there is a TTL cache. Failures are deliberately not
196
+ cached — caching a timeout would pin a healthy domain into `:unknown` for the
197
+ whole TTL:
198
+
199
+ ```ruby
200
+ MailerToGo::SPF.configure do |c|
201
+ c.resolver = MailerToGo::SPF::CachingResolver.new(MailerToGo::SPF::Resolver.new, ttl: 300)
202
+ end
203
+ ```
204
+
205
+ Bring your own if you already speak DNS-over-HTTPS. A DoH resolver can see the
206
+ response code, so it can draw the `[]` / `nil` line exactly where it belongs:
207
+
208
+ ```ruby
209
+ require "net/http"
210
+ require "json"
211
+
212
+ DOH = lambda do |name|
213
+ uri = URI("https://dns.google/resolve?name=#{URI.encode_www_form_component(name)}&type=TXT")
214
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 3, read_timeout: 4) do |http|
215
+ http.request(Net::HTTP::Get.new(uri))
216
+ end
217
+ return nil unless res.code == "200"
218
+
219
+ json = JSON.parse(res.body)
220
+ return [] if json["Status"] == 3 # NXDOMAIN is a definitive "no record"
221
+ return nil unless json["Status"].to_i.zero? # SERVFAIL etc. is inconclusive
222
+
223
+ json.fetch("Answer", []).select { |a| a["type"] == 16 }.map { |a| a["data"].to_s }
224
+ rescue StandardError
225
+ nil
226
+ end
227
+
228
+ MailerToGo::SPF.authorize("example.com", resolver: DOH)
229
+ ```
230
+
231
+ And in tests, a resolver is a hash and a lambda:
232
+
233
+ ```ruby
234
+ zone = {
235
+ "example.com" => ["v=spf1 include:_spf.mailertogo.net ~all"],
236
+ "_spf.mailertogo.net" => ["v=spf1 ip4:192.0.2.10 ~all"],
237
+ }
238
+
239
+ MailerToGo::SPF.authorize("example.com", resolver: ->(name) { zone.fetch(name, []) })
240
+ ```
241
+
242
+ The gem's own suite is built that way: 95 examples, zero network access.
243
+
244
+ ## Configuration
245
+
246
+ Every keyword can be set once instead of per call:
247
+
248
+ ```ruby
249
+ MailerToGo::SPF.configure do |c|
250
+ c.include = "_spf.mailertogo.net" # the mechanism you want authorized
251
+ c.aliases = ["mailertogo.net"] # other names that mean the same sender
252
+ c.resolver = MailerToGo::SPF::Resolver.new
253
+ c.logger = Rails.logger # optional; only used for swallowed errors
254
+ end
255
+ ```
256
+
257
+ The defaults are MailerToGo's own names, so `MailerToGo::SPF.authorize(domain)`
258
+ answers the MailerToGo question with no configuration at all.
259
+
260
+ ## What it deliberately does not do
261
+
262
+ - **Evaluate a message.** There is no `<ip>`/`<sender>` pair here and no
263
+ macro expansion (§7): a term containing `%{i}` costs its DNS lookup and is
264
+ then not followed. This answers "is this sender authorized by this domain",
265
+ which is a setup-time and monitoring question, not a per-message one. For
266
+ per-message evaluation you want a full RFC 7208 evaluator.
267
+ - **Change DNS.** Everything here reads.
268
+ - **Cache by default.** Wrap the resolver if you want that; see above.
269
+
270
+ ## Who made this
271
+
272
+ [MailerToGo](https://mailertogo.com) is an SMTP delivery service — you point
273
+ your app's SMTP settings at it and it handles sending, DKIM signing, and
274
+ delivery reporting. This engine is the one that runs behind our own domain
275
+ setup and monitoring, extracted because the SPF-shaped problems it solves are
276
+ not specific to us: any service that asks customers to add an `include:` has
277
+ customers who publish a second record, hardcode IPs, or sail past the ten-lookup
278
+ cap.
279
+
280
+ ## Contributing
281
+
282
+ Bug reports and pull requests are welcome at
283
+ <https://github.com/aluminumio/mailertogo-spf>. A record shape from the wild
284
+ that this gem gets wrong makes an especially good issue — please include the
285
+ records themselves.
286
+
287
+ ```console
288
+ $ bundle install
289
+ $ bundle exec rspec
290
+ $ bundle exec rubocop
291
+ ```
292
+
293
+ ## Licence
294
+
295
+ MIT. See [LICENSE](LICENSE).