mailertogo-spf 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a2e188c35f296a679f4be3be3e89c2a6c15a3b1bedd4a9f3c8f2fe0bd8480cf
4
- data.tar.gz: '0359e4412f1c81bf20e71b99a088dcd60b58b6125abadae058a2f2990a808296'
3
+ metadata.gz: 581850ec95dfe6d56b2fa63dce987667bde0cb512818e8d5f4cd36d7492b68ab
4
+ data.tar.gz: 4efc0919f45c40599e045fd724b32550611cee5ae142e92f029174808fd93225
5
5
  SHA512:
6
- metadata.gz: '038c312b2ad13a04b55953d61d129a9fd928dd09d4a6eaf38ace13f7b1bab26cfe963b9e04002480ad337e26e4ff1114625860de19775d9c19a023a1c3f7c320'
7
- data.tar.gz: 9eecc4f061f6242edd06fc5fa083b95b16023047602d9515ab449121b718445b0e4222be3231fbfcb9e4c9f46bd5c059c0efd9d447435abba6b0b4d3b3db62eb
6
+ metadata.gz: 061357e0bb7fee158f333d4f40acd28f2dbb6306de1347450c7704e022cd1a29925c3f592d655632f5fab580b181a5ede52dc693f7d8d0bf6395a03c55d6735b
7
+ data.tar.gz: d7244ab5481f550f1213317fc9b4ac8b1d3d8f887bd359c4cdf6102a208fc6a99858c89e0f2aa1810dc6a0db166f50be9df9e9e6508f2f4922ec1c61048094be
data/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.0] - 2026-08-17
8
+
9
+ Answers a second question about a record: not only "does it authorize me, and
10
+ what did that cost", but "what does it cost a receiver that has to evaluate all
11
+ of it" — the number every other SPF checker reports, and the one a domain owner
12
+ compares against.
13
+
14
+ ### Added
15
+
16
+ - `MailerToGo::SPF.chain_audit` / `MailerToGo::SPF::ChainAudit` — prices a whole
17
+ record tree against the RFC 7208 §4.6.4 budget, term by term, with a running
18
+ total. Deliberately a different number from `Result#lookups`, which stops
19
+ where the receiver stops (§4.6.2): a record can authorize you at a cost of 10
20
+ while costing 11 overall and being broken for everyone listed after you.
21
+ Reports `over_limit?`, `headroom`, `partial?` (part of the chain did not
22
+ resolve, so the total is a floor) and `capped?` (past `CEILING`, where the
23
+ exact number stops changing what anyone should do), and names the chain
24
+ defects found on the way: `targets_without_spf` (§5.2) and
25
+ `duplicated_in_chain` (§4.5). Pass `record:` to price a record that is not
26
+ published yet, or omit it to have the apex resolved.
27
+ - `MailerToGo::SPF::Term` — one term of a record as an immutable object that
28
+ answers questions (`include?`, `querying?`, `unreachable?`, `unknown?`,
29
+ `qualifier_meaning`, `all_suffix`, `kind`) instead of being re-matched at
30
+ every call site. Subclassable, and built through `term_class:` on both
31
+ `Record.parse_terms` and `ChainAudit`, so a consumer can hang its own
32
+ human-facing copy off a term without standing up a second parser behind it.
33
+ - `Record.parse_terms(record, term_class:)` — a record's terms as `Term`s,
34
+ numbered from 1 in record order. `Record.terms` still returns strings.
35
+ - `MailerToGo::SPF.normalize_hostname` / `.hostname?` (and
36
+ `MailerToGo::SPF::Hostname`) — the gate for untrusted input in front of
37
+ anything that resolves DNS on request. Forgiving about shape (a pasted URL,
38
+ an email address, a trailing dot), strict about the result: a syntactically
39
+ valid hostname, or nil. Distinct from `Record.normalize_name`, which
40
+ normalizes a name that came out of a record and never rejects.
41
+
42
+ ### Changed
43
+
44
+ - The qualifier map, the querying-mechanism list, the modifier regex and the
45
+ `all`-with-junk regex are now defined once, on `Term`, and read from there by
46
+ `Authorization` and `MergePlan`. `Authorization::QUERYING_MECHANISMS` and
47
+ `ALL_QUALIFIERS` keep their names and values.
48
+ - `MergePlan::ALL_TERM` and `MergePlan::MODIFIER_TERM` are removed; `MergePlan`
49
+ asks `Term` what a term is. Behaviour of the merge is unchanged.
50
+
7
51
  ## [0.1.0] - 2026-08-16
8
52
 
9
53
  First release. Extracted from the SPF engine MailerToGo runs behind its own
@@ -30,4 +74,5 @@ domain setup and monitoring.
30
74
  `Resolv::DNS` default and an optional `CachingResolver`. No runtime
31
75
  dependencies, no Rails.
32
76
 
77
+ [0.2.0]: https://github.com/aluminumio/mailertogo-spf/releases/tag/v0.2.0
33
78
  [0.1.0]: https://github.com/aluminumio/mailertogo-spf/releases/tag/v0.1.0
data/README.md CHANGED
@@ -23,6 +23,10 @@ stops at the first mechanism that matches (RFC 7208 §4.6.2), and counts
23
23
  DNS-querying terms against §4.6.4's cap of 10 — the same arithmetic a receiver
24
24
  does, so a record this gem passes is a record that passes in the wild.
25
25
 
26
+ It answers three questions about a name: does its SPF **authorize** you, what
27
+ should it **publish** given what is already there, and what does the record
28
+ **cost** a receiver that evaluates all of it.
29
+
26
30
  No Rails. No runtime dependencies. DNS goes through an injectable resolver, so
27
31
  your test suite never touches the network.
28
32
 
@@ -173,6 +177,138 @@ plan.value_for(name: "example.com", value: "v=spf1 include:_spf.mailertogo.net ~
173
177
  # => "v=spf1 include:_spf.google.com include:_spf.mailertogo.net ~all"
174
178
  ```
175
179
 
180
+ ## What does this record cost?
181
+
182
+ `authorize` counts the lookups spent up to the point where **you** match,
183
+ because §4.6.2 ends a receiver's evaluation at the first matching mechanism.
184
+ That is the right number for a gating decision and the wrong number for a page
185
+ about the record itself, where the question is what the record costs a receiver
186
+ that has to evaluate *all* of it — the number every other SPF checker reports.
187
+
188
+ `chain_audit` walks the whole tree and prices it against the §4.6.4 budget of
189
+ ten, attaching each cost to the term that incurred it:
190
+
191
+ ```ruby
192
+ audit = MailerToGo::SPF.chain_audit("example.com")
193
+
194
+ audit.total # => 3
195
+ audit.limit # => 10
196
+ audit.headroom # => 7 — lookups still available before the cap
197
+ audit.over_limit? # => false
198
+
199
+ audit.terms.map { |t| [t.raw, t.lookups, t.running_total] }
200
+ # => [["include:_spf.google.com", 3, 3], # itself, plus the two includes inside it
201
+ # ["ip4:198.51.100.7", 0, 3], # already an address; no DNS
202
+ # ["~all", 0, 3]]
203
+ ```
204
+
205
+ An `include:` costs one lookup **plus everything the record it pulls in costs**,
206
+ which is why a record with three terms can be most of the way through the
207
+ budget. That is the arithmetic people get wrong by hand, and the reason a
208
+ domain that has added one provider too many cannot see it in the record.
209
+
210
+ The two numbers can disagree about the same record, and both are right:
211
+
212
+ ```ruby
213
+ # v=spf1 include:p1… ×8 include:relay.example.net include:_spf.mailertogo.net ~all
214
+
215
+ MailerToGo::SPF.authorize("example.com").lookups # => 10 — a receiver matches you and stops
216
+ MailerToGo::SPF.chain_audit("example.com").total # => 11 — evaluating all of it costs 11
217
+ ```
218
+
219
+ Your mail passes at every receiver today. The record is still over the cap, so
220
+ everything listed *after* your include has already stopped passing, and the
221
+ first person to add a provider breaks yours too. Report only the first number
222
+ and you tell that domain owner their record is fine.
223
+
224
+ The audit also names the defects it finds on the way down, each of which
225
+ permerrors the whole evaluation rather than quietly doing nothing:
226
+
227
+ ```ruby
228
+ audit.targets_without_spf # => ["nothing.example.net"] — include: of a name with no SPF (§5.2)
229
+ audit.duplicated_in_chain # => ["two.example.net"] — two v=spf1 records in the chain (§4.5)
230
+ ```
231
+
232
+ Two honesty flags, because a count you could not finish must never read as "it
233
+ fits": `partial?` (part of the chain did not resolve) and `capped?` (the record
234
+ is so far past the cap that we stopped resolving — 20 lookups and 200 are the
235
+ same record to a receiver). Either one makes `total` a floor, and `headroom`
236
+ returns `nil` rather than a reassuring number.
237
+
238
+ Pass `record:` to price a line that is not published yet, and omit it to have
239
+ the apex resolved for you:
240
+
241
+ ```ruby
242
+ MailerToGo::SPF.chain_audit("example.com", record: "v=spf1 include:a.example.net include:b.example.net -all")
243
+ ```
244
+
245
+ ### Terms
246
+
247
+ A `Term` is one term of a record, asked questions instead of pattern-matched:
248
+
249
+ ```ruby
250
+ terms = MailerToGo::SPF::Record.parse_terms("v=spf1 include:_spf.mailertogo.net ~all;google-site-verification=abc")
251
+
252
+ terms.first.mechanism # => "include"
253
+ terms.first.target # => "_spf.mailertogo.net"
254
+ terms.first.querying? # => true — it spends from the §4.6.4 budget
255
+
256
+ terms.last.all? # => true — still a terminal `all`, junk and all
257
+ terms.last.qualifier_meaning # => :softfail
258
+ terms.last.all_suffix # => ";google-site-verification=abc"
259
+ ```
260
+
261
+ That last one is the case worth having: records ending
262
+ `~all;google-site-verification=…` are real and not rare, and reading the whole
263
+ token as junk loses the record's `all` — and with it the domain's entire policy
264
+ for unauthorized mail.
265
+
266
+ There is deliberately **no English sentence** on a term. A description of what a
267
+ term means is product voice; it belongs to whoever is writing to their own
268
+ customers, in their own words. What the gem gives you instead is somewhere to
269
+ put it — subclass `Term`, and both `Record.parse_terms` and `ChainAudit` will
270
+ build and price yours:
271
+
272
+ ```ruby
273
+ class AnnotatedTerm < MailerToGo::SPF::Term
274
+ def meaning
275
+ return "Everything else is marked, not rejected." if all? && qualifier == "~"
276
+ return "Applies #{target}'s own SPF record here." if include?
277
+
278
+
279
+ end
280
+ end
281
+
282
+ MailerToGo::SPF.chain_audit("example.com", term_class: AnnotatedTerm).terms.map(&:meaning)
283
+ ```
284
+
285
+ ## Untrusted input
286
+
287
+ If the hostname came from a form, an API parameter or an uploaded file rather
288
+ than from your own database, check it before you resolve anything: an SPF walk
289
+ is recursive DNS performed on request.
290
+
291
+ ```ruby
292
+ MailerToGo::SPF.normalize_hostname("https://WWW.Example.com/pricing?x=1") # => "www.example.com"
293
+ MailerToGo::SPF.normalize_hostname("billing@example.com") # => "example.com"
294
+ MailerToGo::SPF.normalize_hostname("example.com:5353") # => "example.com"
295
+ MailerToGo::SPF.normalize_hostname("v=spf1") # => nil
296
+ MailerToGo::SPF.hostname?("192.0.2.1") # => false
297
+ ```
298
+
299
+ Forgiving about shape — people paste URLs and email addresses into a box
300
+ labelled "domain" and are not wrong to expect that to work — and strict about
301
+ the result: a syntactically valid hostname, or `nil`. A scheme, a port, a path,
302
+ a query string or an address literal is stripped or refused before any resolver
303
+ sees it, and there is no way to name a resolver or a port through it.
304
+
305
+ It is a separate call rather than something folded into `authorize`, on purpose.
306
+ "That is not a hostname" is a fact about your **input**; the statuses on
307
+ `Result` are facts about somebody's **DNS**, and conflating the two would mean
308
+ inventing a sixth status for a mistake DNS had nothing to do with. It is also
309
+ not `Record.normalize_name`, which lowercases a name that came out of a record
310
+ and never rejects.
311
+
176
312
  ## DNS
177
313
 
178
314
  A resolver is anything that responds to `#call(name)` and returns:
@@ -239,7 +375,7 @@ zone = {
239
375
  MailerToGo::SPF.authorize("example.com", resolver: ->(name) { zone.fetch(name, []) })
240
376
  ```
241
377
 
242
- The gem's own suite is built that way: 95 examples, zero network access.
378
+ The gem's own suite is built that way: 137 examples, zero network access.
243
379
 
244
380
  ## Configuration
245
381
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "set"
4
4
  require "mailertogo/spf/record"
5
+ require "mailertogo/spf/term"
5
6
  require "mailertogo/spf/result"
6
7
  require "mailertogo/spf/sender"
7
8
 
@@ -49,12 +50,13 @@ module MailerToGo
49
50
  # record.
50
51
  MAX_DEPTH = 10
51
52
 
52
- # Mechanisms that consume one DNS lookup from the budget above.
53
- QUERYING_MECHANISMS = %w[include a mx ptr exists].freeze
54
-
55
- # RFC 7208 §4.6.2 the qualifier on a mechanism, here only ever read off
56
- # the record's terminal `all`. See Result#all_qualifier.
57
- ALL_QUALIFIERS = { "+" => :pass, "-" => :fail, "~" => :softfail, "?" => :neutral }.freeze
53
+ # Mechanisms that consume one DNS lookup from the budget above, and the
54
+ # §4.6.2 qualifier map (here only ever read off the record's terminal
55
+ # `all` — see Result#all_qualifier). Both are RFC anatomy and live on
56
+ # Term, which is where a caller goes to ask what a term IS; named here
57
+ # because this is where they are spent.
58
+ QUERYING_MECHANISMS = Term::QUERYING
59
+ ALL_QUALIFIERS = Term::QUALIFIERS
58
60
 
59
61
  # hostname — the domain whose SPF we are reading.
60
62
  # sender — a Sender: the names that mean "me".
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "mailertogo/spf/record"
5
+ require "mailertogo/spf/term"
6
+ require "mailertogo/spf/authorization"
7
+
8
+ module MailerToGo
9
+ module SPF
10
+ # Prices a published SPF record against the RFC 7208 §4.6.4 lookup budget,
11
+ # term by term, and notes the chain defects found on the way.
12
+ #
13
+ # Why this is not Authorization. That class answers "does this record
14
+ # authorise ME?", and §4.6.2 ends a receiver's evaluation at the first
15
+ # mechanism that matches — so it deliberately counts only the lookups spent
16
+ # up to the match. That is the right number for a gating decision (a record
17
+ # whose match lands on term 10 of an 11-term record passes at every real
18
+ # receiver, and calling it a permerror would be wrong) and the wrong number
19
+ # for describing the record itself, where the question is what it costs a
20
+ # receiver that has to evaluate ALL of it. That second number is what every
21
+ # SPF checker reports and the one people compare against, and the two can
22
+ # legitimately disagree about the same record — one authorises you at a cost
23
+ # of 10 while itself costing 11 and being broken for everybody past your
24
+ # include.
25
+ #
26
+ # So: walk the whole tree, charge every querying term, and hand back the
27
+ # cost attached to the term that incurred it.
28
+ #
29
+ # Bounded three ways, because this is the sort of thing that ends up behind
30
+ # an unauthenticated "check my domain" box resolving whatever a stranger
31
+ # typed: the §4.6.4 cap itself, a depth limit, and a hard CEILING past which
32
+ # we stop resolving — once a record is over budget the exact number no
33
+ # longer changes what anyone should do about it.
34
+ #
35
+ # Reads DNS through the same injected resolver as everything else, so behind
36
+ # a CachingResolver an audit of a name you have already authorised is
37
+ # largely cache hits.
38
+ class ChainAudit
39
+ # RFC 7208 §4.6.4 — 10 is legal, 11 permerrors. The same cap Authorization
40
+ # enforces; named here because this class reports against it.
41
+ LIMIT = Authorization::MAX_DNS_LOOKUPS
42
+
43
+ # Belt-and-suspenders against a pathological tree, exactly as in the
44
+ # engine: the budget below is the real bound, this stops runaway recursion
45
+ # on a wide shallow record.
46
+ MAX_DEPTH = Authorization::MAX_DEPTH
47
+
48
+ # Stop resolving well past the cap. A record needing 20 lookups and one
49
+ # needing 200 are the same record to a receiver: permerror. Past this the
50
+ # total is reported as a floor (see #capped?) rather than chased.
51
+ CEILING = 2 * LIMIT
52
+
53
+ attr_reader :hostname, :record, :terms, :total, :targets_without_spf, :duplicated_in_chain
54
+
55
+ # record — the record to price. Pass nil to have the apex resolved from
56
+ # DNS at `hostname`, which is the whole question ("what does
57
+ # this domain's SPF cost?") asked in one call.
58
+ # hostname — the name the record is published at. Guards a record that
59
+ # includes itself, and is where a nil record is resolved from.
60
+ # resolver — anything responding to #call(name); see Resolver.
61
+ # term_class— a Term subclass to build the terms as, for a caller that
62
+ # hangs its own copy off a term.
63
+ def self.call(hostname:, resolver:, record: nil, term_class: nil)
64
+ new(hostname: hostname, resolver: resolver, record: record, term_class: term_class).run
65
+ end
66
+
67
+ def initialize(hostname:, resolver:, record: nil, term_class: nil)
68
+ @record = record&.to_s
69
+ @hostname = Record.normalize_name(hostname)
70
+ @resolver = resolver
71
+ @term_class = term_class || Term
72
+ @spent = 0
73
+ @total = 0
74
+ @terms = []
75
+ @targets_without_spf = []
76
+ @duplicated_in_chain = []
77
+ @resolved = true
78
+ @partial = false
79
+ @capped = false
80
+ # The queried name guards a record that includes itself.
81
+ @seen = Set.new([@hostname])
82
+ end
83
+
84
+ def run
85
+ resolve_apex if @record.nil?
86
+
87
+ parsed = Record.parse_terms(@record, term_class: @term_class)
88
+ # §6.1 — a record containing `all` ignores its redirect= outright,
89
+ # wherever in the record the redirect sits.
90
+ terminal = parsed.any?(&:all?)
91
+ seen_all = false
92
+
93
+ parsed.each do |term|
94
+ unreachable = seen_all || (term.redirect? && terminal)
95
+ # Rebuilt rather than mutated: unreachability is a fact about the
96
+ # term's POSITION, which only this loop knows, and a term is frozen.
97
+ term = @term_class.new(raw: term.raw, position: term.position, unreachable: unreachable)
98
+ cost = unreachable ? 0 : charge(term, depth: 0)
99
+ @terms << term.priced(lookups: cost, running_total: @spent)
100
+ seen_all ||= term.all?
101
+ end
102
+
103
+ @total = @spent
104
+ self
105
+ end
106
+
107
+ def limit = LIMIT
108
+
109
+ # Is there a record here at all? False when the name publishes no v=spf1
110
+ # record (or DNS did not answer — see #resolved?).
111
+ def published? = !@record.to_s.empty?
112
+
113
+ # Did DNS answer when we resolved the apex ourselves? Always true when the
114
+ # record was handed to us. A resolver hiccup is not "this domain has no
115
+ # SPF", and the two must never be reported as the same thing.
116
+ def resolved? = @resolved
117
+
118
+ # More than §4.6.4 allows: 10 is legal, 11 permerrors.
119
+ def over_limit? = @total.to_i > LIMIT
120
+
121
+ # Part of the chain did not resolve, so the total is a FLOOR. Never report
122
+ # "this fits" from a count we could not finish — the direction that is
123
+ # dangerous to get wrong is the reassuring one.
124
+ def partial? = @partial
125
+
126
+ # We stopped walking at CEILING: the record is far past the cap and the
127
+ # real total is higher than the one reported. Same treatment as partial —
128
+ # the number is a floor — for a different reason.
129
+ def capped? = @capped
130
+
131
+ # How many lookups are still available before the cap, or nil when we
132
+ # cannot say (a floor cannot answer "how much room is left").
133
+ def headroom
134
+ return nil if partial? || capped?
135
+
136
+ [LIMIT - @total.to_i, 0].max
137
+ end
138
+
139
+ private
140
+
141
+ # No record was supplied: read the apex ourselves.
142
+ def resolve_apex
143
+ txts = @resolver.call(@hostname)
144
+ if txts.nil?
145
+ @resolved = false
146
+ @partial = true
147
+ @record = ""
148
+ return
149
+ end
150
+
151
+ records = Array(txts).map { |t| Record.normalize_txt(t) }.select { |t| Record.spf_record?(t) }
152
+ # §4.5 — two records at the apex permerror the evaluation just as surely
153
+ # as two anywhere else in the chain, so they are noted the same way. We
154
+ # price the first, because there has to be something to read.
155
+ @duplicated_in_chain << @hostname if records.size > 1
156
+ @record = records.first.to_s
157
+ end
158
+
159
+ # Spend the budget this term costs and return the amount.
160
+ def charge(term, depth:)
161
+ before = @spent
162
+ if term.querying?
163
+ @spent += 1
164
+ # a / mx / ptr / exists cost exactly one query each and expand no
165
+ # further; include: and redirect= pull in another record, and
166
+ # everything in it.
167
+ descend(term.target, depth) if term.include? || term.redirect?
168
+ end
169
+ @spent - before
170
+ end
171
+
172
+ def descend(target, depth)
173
+ name = Record.normalize_name(target.to_s)
174
+ return if name.empty?
175
+ # Macros (%{i} etc., §7) are per-message: the lookup is charged, the
176
+ # branch cannot be followed.
177
+ return if name.include?("%")
178
+ return if depth >= MAX_DEPTH
179
+ return unless @seen.add?(name) # loop guard (§11.1)
180
+
181
+ if @spent > CEILING
182
+ @capped = true
183
+ return
184
+ end
185
+
186
+ txts = @resolver.call(name)
187
+ if txts.nil?
188
+ @partial = true
189
+ return
190
+ end
191
+
192
+ records = Array(txts).map { |t| Record.normalize_txt(t) }.select { |t| Record.spf_record?(t) }
193
+ # §5.2 — an include: whose target publishes no SPF record is a permerror
194
+ # for the whole evaluation, not a term that quietly does nothing.
195
+ return @targets_without_spf << name if records.empty?
196
+ # §4.5 — two records anywhere in the chain permerror the whole thing.
197
+ return @duplicated_in_chain << name if records.size > 1
198
+
199
+ walk(records.first, depth + 1)
200
+ end
201
+
202
+ def walk(record, depth)
203
+ parsed = Record.parse_terms(record, term_class: @term_class)
204
+ terminal = parsed.any?(&:all?)
205
+
206
+ parsed.each do |term|
207
+ break if term.all? # nothing after `all` is ever evaluated
208
+ next if term.redirect? && terminal # §6.1
209
+
210
+ charge(term, depth: depth)
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailerToGo
4
+ module SPF
5
+ # Is this untrusted input a name we are willing to resolve?
6
+ #
7
+ # Everything else in this gem takes a hostname it trusts. This is the gate
8
+ # for the case where it did not come from your own database — a box on a
9
+ # page labelled "domain", an API parameter, a CSV somebody uploaded. Asking
10
+ # a resolver to walk an SPF chain is recursive DNS performed on request, so
11
+ # what gets handed to it should be a plausible DNS name and nothing else:
12
+ # anything that could steer the lookup somewhere unintended — a scheme, a
13
+ # port, a path, a query string, an address literal — is stripped or refused
14
+ # here, before any resolver sees it.
15
+ #
16
+ # It is forgiving about SHAPE, because people paste
17
+ # "https://www.example.com/pricing" or "billing@example.com" into a box
18
+ # labelled "domain" and are not wrong to expect that to work, and strict
19
+ # about the RESULT: a syntactically valid hostname, or nil.
20
+ #
21
+ # Distinct from Record.normalize_name, and deliberately so. That one
22
+ # lowercases a name that came out of an SPF record and drops its root dot;
23
+ # it never rejects, because a name inside a record is already as trusted as
24
+ # the record. This one is a gate, and its job is to say no.
25
+ module Hostname
26
+ module_function
27
+
28
+ # RFC 1035 §2.3.4 — 253 octets of presentation form, 63 per label.
29
+ MAX_LENGTH = 253
30
+
31
+ # A DNS label. Underscores are allowed because SPF names are real targets
32
+ # people check — `_spf.google.com` and `_spf.mailertogo.net` are the two
33
+ # most likely things anyone types into such a box after their own domain.
34
+ LABEL = /\A[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\z/
35
+
36
+ # An address literal is not a name to resolve SPF at.
37
+ IPV4 = /\A\d{1,3}(\.\d{1,3}){3}\z/
38
+
39
+ # A TLD is alphabetic, so "1.2" and "v=spf1" never reach a resolver.
40
+ TLD = /\A[a-z]{2,}\z/
41
+
42
+ # The hostname to resolve, or nil when the input is not one.
43
+ def parse(input)
44
+ host = input.to_s.strip.downcase
45
+ return nil if host.empty?
46
+
47
+ host = host.sub(%r{\A[a-z][a-z0-9+.-]*://}, "") # a pasted URL
48
+ host = host.split("@").last.to_s # an email address
49
+ host = host[%r{\A[^/?#]*}].to_s # path, query, fragment
50
+ host = host.split(":").first.to_s # never honour a supplied port
51
+ host = host.delete_prefix("[").delete_suffix("]") # an IPv6 literal in URL form
52
+ host = host.chomp(".") # the root dot is optional
53
+
54
+ return nil if host.empty? || host.length > MAX_LENGTH
55
+ return nil if host.match?(IPV4)
56
+
57
+ # No IDN here: resolvers speak ASCII, and guessing at an encoding for
58
+ # somebody's DNS name is worse than telling them to paste the A-label.
59
+ labels = host.split(".", -1)
60
+ return nil if labels.size < 2
61
+ return nil unless labels.all? { |label| label.match?(LABEL) }
62
+ return nil unless labels.last.match?(TLD)
63
+
64
+ host
65
+ end
66
+
67
+ def valid?(input)
68
+ !parse(input).nil?
69
+ end
70
+ end
71
+ end
72
+ end
@@ -43,17 +43,13 @@ module MailerToGo
43
43
  # standalone instruction rather than guessing — better to under-help than to
44
44
  # tell someone to replace a record we could not read.
45
45
  class MergePlan
46
- # A term that ends evaluation: `all`, with its optional qualifier, plus
47
- # any junk glued onto it. The junk is real and surprisingly common
48
- # records ending `~all;google-site-verification=…` exist in the wild,
49
- # where the `;`-joined fragment is not a valid SPF term at all.
50
- ALL_TERM = /\A([+\-~?])?all([^a-z0-9].*)?\z/i
51
-
52
- # A modifier (`redirect=`, `exp=`, or an unknown one) rather than a
53
- # mechanism. Modifiers are position-independent (§4.6.1), so they survive
54
- # the merge even when they trail the record's `all`.
55
- MODIFIER_TERM = /\A([a-z][a-z0-9\-_.]*)=/i
56
-
46
+ # What a term IS is it the terminal `all` (with whatever junk is glued
47
+ # onto it), is it a modifier, does it name the sender is asked of Term
48
+ # rather than re-matched here. There was a second, informal copy of that
49
+ # knowledge in this file; two readings of the same term is exactly how a
50
+ # record ending `~all;google-site-verification=…` ends up merged one way
51
+ # and described another.
52
+ #
57
53
  # name — the DNS name the record goes at.
58
54
  # record — the standalone record you would otherwise have told them
59
55
  # to publish, e.g. Sender#record. It is what a :publish
@@ -138,26 +134,25 @@ module MailerToGo
138
134
  (theirs + ours).each do |record|
139
135
  seen_all = false
140
136
 
141
- Record.terms(record).each do |term|
142
- if (m = ALL_TERM.match(term))
137
+ Record.parse_terms(record).each do |term|
138
+ if term.all?
143
139
  seen_all = true
144
140
  # First `all` across the ordered records wins — theirs, not ours.
145
141
  # A bare `all` is `+all` (§4.6.2); spell it out so the merged line
146
142
  # says plainly what it does.
147
- @all_qualifier ||= m[1] || "+"
148
- if m[2] && !m[2].strip.empty?
149
- @notes << "Dropped #{m[2].strip.inspect}, which was glued onto your #{m[1]}all " \
143
+ @all_qualifier ||= term.qualifier || "+"
144
+ if (junk = term.all_suffix)
145
+ @notes << "Dropped #{junk.inspect}, which was glued onto your #{term.qualifier}all " \
150
146
  "and isn't a valid SPF term — publish it as its own TXT record if you still need it."
151
147
  end
152
148
  next
153
149
  end
154
150
 
155
- if MODIFIER_TERM.match?(term)
151
+ if term.modifier
156
152
  # redirect=/exp= are modifiers, not mechanisms: they apply to the
157
153
  # whole record wherever they sit, so keep them (deduped by
158
154
  # modifier name).
159
- key = MODIFIER_TERM.match(term)[1].downcase
160
- modifiers << term unless modifiers.any? { |t| MODIFIER_TERM.match(t)[1].casecmp?(key) }
155
+ modifiers << term unless modifiers.any? { |t| t.modifier == term.modifier }
161
156
  next
162
157
  end
163
158
 
@@ -165,13 +160,14 @@ module MailerToGo
165
160
  # Unreachable in the published record (nothing after `all` is ever
166
161
  # evaluated). Dropping it preserves the record's exact behavior;
167
162
  # keeping it would newly authorize a sender receivers ignore today.
168
- @notes << "Dropped #{term.inspect}, which sat after your #{@all_qualifier}all and was never evaluated."
163
+ @notes << "Dropped #{term.raw.inspect}, which sat after your #{@all_qualifier}all " \
164
+ "and was never evaluated."
169
165
  next
170
166
  end
171
167
 
172
168
  next if sender_term?(term)
173
169
 
174
- mechanisms << term unless mechanisms.any? { |t| t.casecmp?(term) }
170
+ mechanisms << term.raw unless mechanisms.any? { |t| t.casecmp?(term.raw) }
175
171
  end
176
172
  end
177
173
 
@@ -179,7 +175,7 @@ module MailerToGo
179
175
  # everything and ends evaluation.
180
176
  terms = mechanisms + ["include:#{@include_name}"]
181
177
  terms << "#{@all_qualifier}all" if @all_qualifier
182
- terms += modifiers
178
+ terms += modifiers.map(&:raw)
183
179
  "v=spf1 #{terms.join(" ")}"
184
180
  end
185
181
 
@@ -208,13 +204,16 @@ module MailerToGo
208
204
  # that reaches the sender through somebody else's include is still THEIR
209
205
  # policy record.
210
206
  def ours?(record)
211
- Record.terms(record).any? { |term| sender_term?(term) }
207
+ Record.parse_terms(record).any? { |term| sender_term?(term) }
212
208
  end
213
209
 
210
+ # Takes a Term, not a string: "does this name us" is a question about the
211
+ # term's target, and Term already knows how to find one.
214
212
  def sender_term?(term)
215
- t = Record.strip_qualifier(term)
216
- target = t[/\Ainclude:(.+)\z/i, 1] || t[/\Aredirect=(.+)\z/i, 1]
217
- !target.nil? && !target.empty? && @sender.covers?(target)
213
+ return false unless term.include? || term.redirect?
214
+
215
+ target = term.target
216
+ !target.nil? && @sender.covers?(target)
218
217
  end
219
218
 
220
219
  def same_terms?(a, b)
@@ -76,9 +76,26 @@ module MailerToGo
76
76
  end
77
77
 
78
78
  # The terms of a record, without the leading "v=spf1".
79
+ #
80
+ # Strings, deliberately: this is the raw-text layer, and the engine walks
81
+ # terms in tight loops where a string is exactly what it wants. Ask for
82
+ # .parse_terms when you want to interrogate a term rather than match it.
79
83
  def terms(record)
80
84
  record.to_s.split(/\s+/).drop(1)
81
85
  end
86
+
87
+ # The same terms as Term objects, numbered from 1 in record order, so a
88
+ # caller can ask each one what it is instead of re-deriving that from the
89
+ # string. `term_class:` takes a Term subclass — that is the seam for
90
+ # hanging your own copy off a term without a parallel parser behind it.
91
+ #
92
+ # Term is resolved at call time rather than required at the top of this
93
+ # file: Term is built ON this module, and the raw-text layer should not
94
+ # have to know about the layer above it to hand one back.
95
+ def parse_terms(record, term_class: nil)
96
+ term_class ||= Term
97
+ terms(record).each_with_index.map { |raw, i| term_class.new(raw: raw, position: i + 1) }
98
+ end
82
99
  end
83
100
  end
84
101
  end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mailertogo/spf/record"
4
+
5
+ module MailerToGo
6
+ module SPF
7
+ # One term of an SPF record: what it is, and what it costs.
8
+ #
9
+ # `Record` reads the raw text — split a record into terms, strip a
10
+ # qualifier, name a mechanism. This is the same knowledge asked as
11
+ # questions instead of pattern-matched at every call site: `term.include?`,
12
+ # `term.querying?`, `term.unreachable?`. Nothing here re-parses what
13
+ # `Record` already parses; it builds on it, which is the only way two
14
+ # readings of the same term cannot drift apart.
15
+ #
16
+ # Deliberately NOT here: any English sentence about what a term means to a
17
+ # human. A description is product voice — it belongs to whoever is writing
18
+ # to their own customers, in their own words, and no gem should be in the
19
+ # business of writing it. What a consumer wants is a place to hang that
20
+ # copy, so `Term` is designed to be subclassed: every predicate it answers
21
+ # is a predicate your sentence can switch on, and both #priced and
22
+ # `Record.parse_terms(record, term_class:)` build your subclass rather than
23
+ # this class.
24
+ #
25
+ # class AnnotatedTerm < MailerToGo::SPF::Term
26
+ # def meaning
27
+ # return "Everything not matched above is marked, not rejected." if all? && qualifier == "~"
28
+ # …
29
+ # end
30
+ # end
31
+ #
32
+ # Immutable, and frozen on construction: pricing a term builds a copy via
33
+ # #priced rather than mutating it, so a term can never be observed
34
+ # half-priced.
35
+ class Term
36
+ # RFC 7208 §4.6.4 — the mechanisms that cost a DNS query. The lookup
37
+ # budget is a count of these (plus the redirect= modifier).
38
+ QUERYING = %w[include a mx ptr exists].freeze
39
+
40
+ # §4.6.2 — the qualifier a mechanism may carry, and what a match under it
41
+ # means. "+" when absent.
42
+ QUALIFIERS = { "+" => :pass, "-" => :fail, "~" => :softfail, "?" => :neutral }.freeze
43
+
44
+ # Every mechanism SPF defines. Anything else in mechanism position is a
45
+ # syntax error (see #unknown?), not a term that quietly does nothing.
46
+ MECHANISMS = (QUERYING + %w[ip4 ip6 all]).freeze
47
+
48
+ # A modifier (`redirect=`, `exp=`, or an unknown one) rather than a
49
+ # mechanism. Modifiers are name=value and position-independent (§4.6.1).
50
+ MODIFIER = /\A([a-z][a-z0-9\-_.]*)=(.*)\z/i
51
+
52
+ # `all`, with whatever junk is glued onto it. The junk is real and
53
+ # surprisingly common — records ending `~all;google-site-verification=…`
54
+ # exist in the wild, where the `;`-joined fragment is not a valid SPF term
55
+ # at all. Matching it HERE is what keeps such a record's terminal term
56
+ # recognised as an `all` (which ends evaluation, and whose qualifier is
57
+ # the domain's policy) rather than filed as junk and the record read as
58
+ # having no `all` whatsoever.
59
+ ALL = /\Aall([^a-z0-9].*)?\z/i
60
+
61
+ attr_reader :raw, :position, :qualifier, :lookups, :running_total
62
+
63
+ # raw — the term exactly as published.
64
+ # position — 1-based index in the record; 0 when it was not parsed as
65
+ # part of one. For display, and for nothing else.
66
+ # unreachable — the term is never evaluated: it sits after `all`, or it
67
+ # is a redirect= in a record that has an `all`
68
+ # (§5.1/§6.1). Such a term authorises nothing and costs
69
+ # nothing.
70
+ # lookups — DNS lookups this term costs a receiver: itself, plus
71
+ # everything the record it pulls in costs. nil until
72
+ # priced by ChainAudit.
73
+ # running_total — the record's cumulative cost through this term.
74
+ def initialize(raw:, position: 0, unreachable: false, lookups: nil, running_total: nil)
75
+ @raw = raw.to_s
76
+ @position = position
77
+ @unreachable = unreachable
78
+ @lookups = lookups
79
+ @running_total = running_total
80
+ @bare = Record.strip_qualifier(@raw)
81
+ @qualifier = Record.qualifier_of(@raw)
82
+ @modifier_match = MODIFIER.match(@bare)
83
+ @all_match = ALL.match(@bare)
84
+ @mechanism = @modifier_match ? nil : Record.mechanism_of(@bare)
85
+ freeze
86
+ end
87
+
88
+ # A copy carrying the cost ChainAudit measured. `self.class` so a
89
+ # subclass that adds its own copy stays that subclass through pricing.
90
+ def priced(lookups:, running_total:)
91
+ self.class.new(raw: raw, position: position, unreachable: unreachable?,
92
+ lookups: lookups, running_total: running_total)
93
+ end
94
+
95
+ # ── What it is ────────────────────────────────────────────────────────
96
+
97
+ # "include", "ip4", "all"… nil for a modifier or for junk.
98
+ def mechanism
99
+ return "all" if @all_match
100
+
101
+ @mechanism if MECHANISMS.include?(@mechanism)
102
+ end
103
+
104
+ # "redirect", "exp", or another modifier name; nil for a mechanism.
105
+ def modifier
106
+ @modifier_match && @modifier_match[1].downcase
107
+ end
108
+
109
+ # The value after the ":" or "=" — an include target, an IP range, an
110
+ # explanation name. nil for a bare `a`, `mx` or `all`.
111
+ def target
112
+ value = @modifier_match ? @modifier_match[2] : @bare.split(":", 2)[1]
113
+ value.to_s.empty? ? nil : value
114
+ end
115
+
116
+ # :pass | :fail | :softfail | :neutral — what a match under this
117
+ # qualifier means to a receiver (§4.6.2). On the terminal `all` this is
118
+ # the domain's whole policy for unauthorised mail (§5.1).
119
+ def qualifier_meaning = QUALIFIERS[@qualifier || "+"]
120
+
121
+ def all? = mechanism == "all"
122
+ def include? = mechanism == "include"
123
+ def redirect? = modifier == "redirect"
124
+ def ip? = %w[ip4 ip6].include?(mechanism)
125
+
126
+ # Does this term spend from the §4.6.4 budget at all?
127
+ def querying? = QUERYING.include?(mechanism) || redirect?
128
+
129
+ # Never evaluated by a receiver, so it authorises nothing and costs
130
+ # nothing. Set by whoever read the record in order (ChainAudit); a term
131
+ # on its own cannot know what precedes it.
132
+ def unreachable? = @unreachable == true
133
+
134
+ # Not a mechanism SPF defines and not a modifier: junk. Per §4.6 a syntax
135
+ # error in a record permits a receiver to permerror the whole thing, so
136
+ # this is never harmless.
137
+ def unknown? = mechanism.nil? && modifier.nil?
138
+
139
+ # Which of the three a term is: a named mechanism, "modifier", "unknown".
140
+ def kind
141
+ return "modifier" if modifier
142
+ return "unknown" if unknown?
143
+
144
+ mechanism
145
+ end
146
+
147
+ # The junk glued onto an `all` (`~all;google-site-verification=…`), or
148
+ # nil. Worth surfacing: it is inside the record rather than beside it, so
149
+ # it is not the second TXT record its author thought they were writing.
150
+ def all_suffix
151
+ suffix = @all_match && @all_match[1].to_s.strip
152
+ suffix.to_s.empty? ? nil : suffix
153
+ end
154
+
155
+ def to_s = raw
156
+ end
157
+ end
158
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module MailerToGo
4
4
  module SPF
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -2,10 +2,13 @@
2
2
 
3
3
  require "mailertogo/spf/version"
4
4
  require "mailertogo/spf/record"
5
+ require "mailertogo/spf/term"
6
+ require "mailertogo/spf/hostname"
5
7
  require "mailertogo/spf/sender"
6
8
  require "mailertogo/spf/resolver"
7
9
  require "mailertogo/spf/result"
8
10
  require "mailertogo/spf/authorization"
11
+ require "mailertogo/spf/chain_audit"
9
12
  require "mailertogo/spf/plan"
10
13
  require "mailertogo/spf/merge_plan"
11
14
 
@@ -14,7 +17,7 @@ module MailerToGo
14
17
  # the include chain, stops where the receiver stops, and counts DNS lookups
15
18
  # against the RFC 7208 §4.6.4 cap.
16
19
  #
17
- # Two questions, two entry points:
20
+ # Three questions, three entry points:
18
21
  #
19
22
  # MailerToGo::SPF.authorize("example.com")
20
23
  # → does this domain's published SPF authorize me?
@@ -22,6 +25,9 @@ module MailerToGo
22
25
  # MailerToGo::SPF.merge_plan("example.com")
23
26
  # → what should I tell them to publish, given what is already there?
24
27
  #
28
+ # MailerToGo::SPF.chain_audit("example.com")
29
+ # → what does this record cost a receiver, term by term?
30
+ #
25
31
  # Both take `include:` (the mechanism you want authorized) and `aliases:`
26
32
  # (other names that mean the same sender). Both default to MailerToGo's own
27
33
  # names, so the zero-argument form is the useful one for MailerToGo customers
@@ -106,6 +112,30 @@ module MailerToGo
106
112
  )
107
113
  end
108
114
 
115
+ # What does this record cost a receiver that evaluates all of it?
116
+ # Returns a ChainAudit (see chain_audit.rb).
117
+ #
118
+ # This is a different number from Result#lookups and is meant to be: the
119
+ # authorization walk stops where the receiver stops (§4.6.2), while this
120
+ # prices the whole tree against the §4.6.4 budget — the number other SPF
121
+ # checkers report. Notably sender-agnostic; there is no `include:` here,
122
+ # because the record's cost has nothing to do with who is asking.
123
+ #
124
+ # record: price this record instead of resolving one at `hostname`, which
125
+ # is how you price a record that is not published yet.
126
+ def chain_audit(hostname, record: nil, resolver: nil, term_class: nil)
127
+ ChainAudit.call(hostname: hostname, record: record, resolver: resolver || self.resolver,
128
+ term_class: term_class)
129
+ end
130
+
131
+ # Is this untrusted input something we should resolve at all? Returns the
132
+ # normalized hostname, or nil. See hostname.rb — it is a gate, kept
133
+ # separate from the entry points above on purpose, because "that is not a
134
+ # hostname" is a fact about the INPUT and must not be dressed up as a fact
135
+ # about somebody's DNS.
136
+ def normalize_hostname(input) = Hostname.parse(input)
137
+ def hostname?(input) = Hostname.valid?(input)
138
+
109
139
  # The names that mean "me". Public because a caller that asks both
110
140
  # questions about the same sender should build it once.
111
141
  def sender(include: nil, aliases: nil)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mailertogo-spf
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - MailerToGo
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-15 00:00:00.000000000 Z
11
+ date: 2026-08-17 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  An SPF engine that follows include:/redirect= chains, stops at the first
@@ -16,8 +16,10 @@ description: |
16
16
  cap of 10 — so it agrees with what real receivers do instead of
17
17
  string-matching a token. It also plans the record a domain should publish:
18
18
  given what is already at the name, merge one include into the existing
19
- record rather than adding a second v=spf1 record beside it. No Rails, no
20
- runtime dependencies, injectable DNS resolver.
19
+ record rather than adding a second v=spf1 record beside it. And it prices a
20
+ record: what the whole tree costs a receiver that evaluates all of it, term
21
+ by term, against the same cap. No Rails, no runtime dependencies, injectable
22
+ DNS resolver.
21
23
  email:
22
24
  - support@mailertogo.com
23
25
  executables: []
@@ -29,12 +31,15 @@ files:
29
31
  - README.md
30
32
  - lib/mailertogo/spf.rb
31
33
  - lib/mailertogo/spf/authorization.rb
34
+ - lib/mailertogo/spf/chain_audit.rb
35
+ - lib/mailertogo/spf/hostname.rb
32
36
  - lib/mailertogo/spf/merge_plan.rb
33
37
  - lib/mailertogo/spf/plan.rb
34
38
  - lib/mailertogo/spf/record.rb
35
39
  - lib/mailertogo/spf/resolver.rb
36
40
  - lib/mailertogo/spf/result.rb
37
41
  - lib/mailertogo/spf/sender.rb
42
+ - lib/mailertogo/spf/term.rb
38
43
  - lib/mailertogo/spf/version.rb
39
44
  homepage: https://github.com/aluminumio/mailertogo-spf
40
45
  licenses: