mailertogo-spf 0.1.0 → 0.2.1

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: 316234e7966e3e895703bbabc960f8cda0b932d7348e654d7afaf1d97c3ee094
4
+ data.tar.gz: 3a20869ecc92f35b8780672af93e7d38223cbc7e6ae6ca7913b678fae541c37c
5
5
  SHA512:
6
- metadata.gz: '038c312b2ad13a04b55953d61d129a9fd928dd09d4a6eaf38ace13f7b1bab26cfe963b9e04002480ad337e26e4ff1114625860de19775d9c19a023a1c3f7c320'
7
- data.tar.gz: 9eecc4f061f6242edd06fc5fa083b95b16023047602d9515ab449121b718445b0e4222be3231fbfcb9e4c9f46bd5c059c0efd9d447435abba6b0b4d3b3db62eb
6
+ metadata.gz: d04d30b6ec8059c57d6231ffb786ffb4ce0ad8672b6faad7a29b8f215adbee0c97a3cbeb39078a24ac3988d25d273a6b05b77b02fff51a2be146110ee28927a1
7
+ data.tar.gz: 58c318462a651629b0cf7d2f11951dcb419486549a947e82fe5f6195c67f6fc694b636e66686bb87aa4b6aff2c13605f94749f3a6c754795a94f5bd15cdeadfb
data/CHANGELOG.md CHANGED
@@ -4,6 +4,80 @@ 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.1] - 2026-08-17
8
+
9
+ Two `ChainAudit` results could be read as facts when they were really "we did
10
+ not look". Both are corrected in the direction of admitting it.
11
+
12
+ ### Fixed
13
+
14
+ - `ChainAudit#total` (and `#headroom`) are **`nil` rather than `0`** for a name
15
+ that publishes no record, or when DNS did not answer. `0` is a legitimate
16
+ total — `v=spf1 -all` costs exactly that — so a caller that skipped
17
+ `#published?` could report "this record costs 0 lookups" about a domain with
18
+ no record. A record that legitimately costs nothing still reports `0`.
19
+ - Duplicate records **at the hostname itself** are reported by the new
20
+ `#apex_duplicated?` instead of being pushed onto `#duplicated_in_chain`. RFC
21
+ 7208 §4.5 makes them a permerror either way, but the apex is not *in* the
22
+ chain — it is the record being priced. Folding them together made the same
23
+ domain audit differently depending on whether the caller supplied `record:` or
24
+ let the gem resolve the apex, and a caller raising its own duplicate-record
25
+ defect from the records it resolved would have raised it twice.
26
+
27
+ ### Added
28
+
29
+ - `ChainAudit#apex_duplicated?` — `true`/`false` when the gem resolved the apex,
30
+ and `nil` ("cannot say") when the record was supplied or DNS did not answer.
31
+
32
+ ### Changed
33
+
34
+ - `#duplicated_in_chain` no longer contains the audited hostname. Callers that
35
+ want that fact ask `#apex_duplicated?`.
36
+
37
+ ## [0.2.0] - 2026-08-17
38
+
39
+ Answers a second question about a record: not only "does it authorize me, and
40
+ what did that cost", but "what does it cost a receiver that has to evaluate all
41
+ of it" — the number every other SPF checker reports, and the one a domain owner
42
+ compares against.
43
+
44
+ ### Added
45
+
46
+ - `MailerToGo::SPF.chain_audit` / `MailerToGo::SPF::ChainAudit` — prices a whole
47
+ record tree against the RFC 7208 §4.6.4 budget, term by term, with a running
48
+ total. Deliberately a different number from `Result#lookups`, which stops
49
+ where the receiver stops (§4.6.2): a record can authorize you at a cost of 10
50
+ while costing 11 overall and being broken for everyone listed after you.
51
+ Reports `over_limit?`, `headroom`, `partial?` (part of the chain did not
52
+ resolve, so the total is a floor) and `capped?` (past `CEILING`, where the
53
+ exact number stops changing what anyone should do), and names the chain
54
+ defects found on the way: `targets_without_spf` (§5.2) and
55
+ `duplicated_in_chain` (§4.5). Pass `record:` to price a record that is not
56
+ published yet, or omit it to have the apex resolved.
57
+ - `MailerToGo::SPF::Term` — one term of a record as an immutable object that
58
+ answers questions (`include?`, `querying?`, `unreachable?`, `unknown?`,
59
+ `qualifier_meaning`, `all_suffix`, `kind`) instead of being re-matched at
60
+ every call site. Subclassable, and built through `term_class:` on both
61
+ `Record.parse_terms` and `ChainAudit`, so a consumer can hang its own
62
+ human-facing copy off a term without standing up a second parser behind it.
63
+ - `Record.parse_terms(record, term_class:)` — a record's terms as `Term`s,
64
+ numbered from 1 in record order. `Record.terms` still returns strings.
65
+ - `MailerToGo::SPF.normalize_hostname` / `.hostname?` (and
66
+ `MailerToGo::SPF::Hostname`) — the gate for untrusted input in front of
67
+ anything that resolves DNS on request. Forgiving about shape (a pasted URL,
68
+ an email address, a trailing dot), strict about the result: a syntactically
69
+ valid hostname, or nil. Distinct from `Record.normalize_name`, which
70
+ normalizes a name that came out of a record and never rejects.
71
+
72
+ ### Changed
73
+
74
+ - The qualifier map, the querying-mechanism list, the modifier regex and the
75
+ `all`-with-junk regex are now defined once, on `Term`, and read from there by
76
+ `Authorization` and `MergePlan`. `Authorization::QUERYING_MECHANISMS` and
77
+ `ALL_QUALIFIERS` keep their names and values.
78
+ - `MergePlan::ALL_TERM` and `MergePlan::MODIFIER_TERM` are removed; `MergePlan`
79
+ asks `Term` what a term is. Behaviour of the merge is unchanged.
80
+
7
81
  ## [0.1.0] - 2026-08-16
8
82
 
9
83
  First release. Extracted from the SPF engine MailerToGo runs behind its own
@@ -30,4 +104,5 @@ domain setup and monitoring.
30
104
  `Resolv::DNS` default and an optional `CachingResolver`. No runtime
31
105
  dependencies, no Rails.
32
106
 
107
+ [0.2.0]: https://github.com/aluminumio/mailertogo-spf/releases/tag/v0.2.0
33
108
  [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,154 @@ 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
+ audit.apex_duplicated? # => false — two at the hostname itself (§4.5)
231
+ ```
232
+
233
+ `apex_duplicated?` is deliberately separate from `duplicated_in_chain`: the
234
+ hostname is not *in* the chain, it is the record being priced. It answers `nil`
235
+ — "cannot say" — when you supplied the `record:` yourself, because then we never
236
+ looked at the apex. A caller that resolved the apex already knows, and should
237
+ report from what it saw rather than ask twice.
238
+
239
+ A record that does not exist cannot be priced, so `total` and `headroom` are
240
+ `nil` rather than `0`. Check `published?` first:
241
+
242
+ ```ruby
243
+ audit = MailerToGo::SPF.chain_audit("no-spf.example.com")
244
+ audit.published? # => false
245
+ audit.total # => nil — not 0; `v=spf1 -all` legitimately costs 0
246
+ ```
247
+
248
+ Two honesty flags, because a count you could not finish must never read as "it
249
+ fits": `partial?` (part of the chain did not resolve) and `capped?` (the record
250
+ is so far past the cap that we stopped resolving — 20 lookups and 200 are the
251
+ same record to a receiver). Either one makes `total` a floor, and `headroom`
252
+ returns `nil` rather than a reassuring number.
253
+
254
+ Pass `record:` to price a line that is not published yet, and omit it to have
255
+ the apex resolved for you:
256
+
257
+ ```ruby
258
+ MailerToGo::SPF.chain_audit("example.com", record: "v=spf1 include:a.example.net include:b.example.net -all")
259
+ ```
260
+
261
+ ### Terms
262
+
263
+ A `Term` is one term of a record, asked questions instead of pattern-matched:
264
+
265
+ ```ruby
266
+ terms = MailerToGo::SPF::Record.parse_terms("v=spf1 include:_spf.mailertogo.net ~all;google-site-verification=abc")
267
+
268
+ terms.first.mechanism # => "include"
269
+ terms.first.target # => "_spf.mailertogo.net"
270
+ terms.first.querying? # => true — it spends from the §4.6.4 budget
271
+
272
+ terms.last.all? # => true — still a terminal `all`, junk and all
273
+ terms.last.qualifier_meaning # => :softfail
274
+ terms.last.all_suffix # => ";google-site-verification=abc"
275
+ ```
276
+
277
+ That last one is the case worth having: records ending
278
+ `~all;google-site-verification=…` are real and not rare, and reading the whole
279
+ token as junk loses the record's `all` — and with it the domain's entire policy
280
+ for unauthorized mail.
281
+
282
+ There is deliberately **no English sentence** on a term. A description of what a
283
+ term means is product voice; it belongs to whoever is writing to their own
284
+ customers, in their own words. What the gem gives you instead is somewhere to
285
+ put it — subclass `Term`, and both `Record.parse_terms` and `ChainAudit` will
286
+ build and price yours:
287
+
288
+ ```ruby
289
+ class AnnotatedTerm < MailerToGo::SPF::Term
290
+ def meaning
291
+ return "Everything else is marked, not rejected." if all? && qualifier == "~"
292
+ return "Applies #{target}'s own SPF record here." if include?
293
+
294
+
295
+ end
296
+ end
297
+
298
+ MailerToGo::SPF.chain_audit("example.com", term_class: AnnotatedTerm).terms.map(&:meaning)
299
+ ```
300
+
301
+ ## Untrusted input
302
+
303
+ If the hostname came from a form, an API parameter or an uploaded file rather
304
+ than from your own database, check it before you resolve anything: an SPF walk
305
+ is recursive DNS performed on request.
306
+
307
+ ```ruby
308
+ MailerToGo::SPF.normalize_hostname("https://WWW.Example.com/pricing?x=1") # => "www.example.com"
309
+ MailerToGo::SPF.normalize_hostname("billing@example.com") # => "example.com"
310
+ MailerToGo::SPF.normalize_hostname("example.com:5353") # => "example.com"
311
+ MailerToGo::SPF.normalize_hostname("v=spf1") # => nil
312
+ MailerToGo::SPF.hostname?("192.0.2.1") # => false
313
+ ```
314
+
315
+ Forgiving about shape — people paste URLs and email addresses into a box
316
+ labelled "domain" and are not wrong to expect that to work — and strict about
317
+ the result: a syntactically valid hostname, or `nil`. A scheme, a port, a path,
318
+ a query string or an address literal is stripped or refused before any resolver
319
+ sees it, and there is no way to name a resolver or a port through it.
320
+
321
+ It is a separate call rather than something folded into `authorize`, on purpose.
322
+ "That is not a hostname" is a fact about your **input**; the statuses on
323
+ `Result` are facts about somebody's **DNS**, and conflating the two would mean
324
+ inventing a sixth status for a mistake DNS had nothing to do with. It is also
325
+ not `Record.normalize_name`, which lowercases a name that came out of a record
326
+ and never rejects.
327
+
176
328
  ## DNS
177
329
 
178
330
  A resolver is anything that responds to `#call(name)` and returns:
@@ -239,7 +391,7 @@ zone = {
239
391
  MailerToGo::SPF.authorize("example.com", resolver: ->(name) { zone.fetch(name, []) })
240
392
  ```
241
393
 
242
- The gem's own suite is built that way: 95 examples, zero network access.
394
+ The gem's own suite is built that way: 137 examples, zero network access.
243
395
 
244
396
  ## Configuration
245
397
 
@@ -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,236 @@
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 = nil
74
+ @terms = []
75
+ @targets_without_spf = []
76
+ @duplicated_in_chain = []
77
+ # nil until we resolve the apex ourselves — "cannot say", not "no".
78
+ @apex_duplicated = nil
79
+ @resolved = true
80
+ @partial = false
81
+ @capped = false
82
+ # The queried name guards a record that includes itself.
83
+ @seen = Set.new([@hostname])
84
+ end
85
+
86
+ def run
87
+ resolve_apex if @record.nil?
88
+
89
+ parsed = Record.parse_terms(@record, term_class: @term_class)
90
+ # §6.1 — a record containing `all` ignores its redirect= outright,
91
+ # wherever in the record the redirect sits.
92
+ terminal = parsed.any?(&:all?)
93
+ seen_all = false
94
+
95
+ parsed.each do |term|
96
+ unreachable = seen_all || (term.redirect? && terminal)
97
+ # Rebuilt rather than mutated: unreachability is a fact about the
98
+ # term's POSITION, which only this loop knows, and a term is frozen.
99
+ term = @term_class.new(raw: term.raw, position: term.position, unreachable: unreachable)
100
+ cost = unreachable ? 0 : charge(term, depth: 0)
101
+ @terms << term.priced(lookups: cost, running_total: @spent)
102
+ seen_all ||= term.all?
103
+ end
104
+
105
+ # nil, not 0, when there is nothing to price. 0 is a legitimate total —
106
+ # `v=spf1 -all` costs exactly that — so using it for "no record" lets a
107
+ # caller that skipped #published? report "this record costs 0 lookups"
108
+ # about a domain with no record at all.
109
+ @total = published? ? @spent : nil
110
+ self
111
+ end
112
+
113
+ def limit = LIMIT
114
+
115
+ # Is there a record here at all? False when the name publishes no v=spf1
116
+ # record (or DNS did not answer — see #resolved?).
117
+ def published? = !@record.to_s.empty?
118
+
119
+ # Did DNS answer when we resolved the apex ourselves? Always true when the
120
+ # record was handed to us. A resolver hiccup is not "this domain has no
121
+ # SPF", and the two must never be reported as the same thing.
122
+ def resolved? = @resolved
123
+
124
+ # More than §4.6.4 allows: 10 is legal, 11 permerrors.
125
+ def over_limit? = @total.to_i > LIMIT
126
+
127
+ # Part of the chain did not resolve, so the total is a FLOOR. Never report
128
+ # "this fits" from a count we could not finish — the direction that is
129
+ # dangerous to get wrong is the reassuring one.
130
+ def partial? = @partial
131
+
132
+ # We stopped walking at CEILING: the record is far past the cap and the
133
+ # real total is higher than the one reported. Same treatment as partial —
134
+ # the number is a floor — for a different reason.
135
+ def capped? = @capped
136
+
137
+ # Was there more than one v=spf1 record at the hostname itself? §4.5 makes
138
+ # that a permerror just as surely as duplicates inside the chain.
139
+ #
140
+ # nil means "cannot say" rather than "no": the record was handed to us, so
141
+ # we never looked at the apex (or DNS did not answer). Only a caller that
142
+ # let us resolve the apex gets a true/false here — the one that resolved it
143
+ # already knows, and should report from what it saw rather than ask us.
144
+ def apex_duplicated? = @apex_duplicated
145
+
146
+ # How many lookups are still available before the cap, or nil when we
147
+ # cannot say (a floor cannot answer "how much room is left", and neither
148
+ # can a record that does not exist).
149
+ def headroom
150
+ return nil if partial? || capped? || !published?
151
+
152
+ [LIMIT - @total.to_i, 0].max
153
+ end
154
+
155
+ private
156
+
157
+ # No record was supplied: read the apex ourselves.
158
+ def resolve_apex
159
+ txts = @resolver.call(@hostname)
160
+ if txts.nil?
161
+ @resolved = false
162
+ @partial = true
163
+ @record = ""
164
+ return
165
+ end
166
+
167
+ records = Array(txts).map { |t| Record.normalize_txt(t) }.select { |t| Record.spf_record?(t) }
168
+ # §4.5 — two records at the apex permerror the evaluation just as surely
169
+ # as two anywhere else in the chain. It is reported SEPARATELY from
170
+ # `duplicated_in_chain` even so, because the apex is not *in* the chain:
171
+ # it is the record being priced. Folding it in made the same domain audit
172
+ # differently depending on whether the caller resolved the apex or we
173
+ # did, and a caller that raises its own duplicate-record defect from the
174
+ # records it resolved would then raise it twice.
175
+ @apex_duplicated = records.size > 1
176
+ # We price the first, because there has to be something to read.
177
+ @record = records.first.to_s
178
+ end
179
+
180
+ # Spend the budget this term costs and return the amount.
181
+ def charge(term, depth:)
182
+ before = @spent
183
+ if term.querying?
184
+ @spent += 1
185
+ # a / mx / ptr / exists cost exactly one query each and expand no
186
+ # further; include: and redirect= pull in another record, and
187
+ # everything in it.
188
+ descend(term.target, depth) if term.include? || term.redirect?
189
+ end
190
+ @spent - before
191
+ end
192
+
193
+ def descend(target, depth)
194
+ name = Record.normalize_name(target.to_s)
195
+ return if name.empty?
196
+ # Macros (%{i} etc., §7) are per-message: the lookup is charged, the
197
+ # branch cannot be followed.
198
+ return if name.include?("%")
199
+ return if depth >= MAX_DEPTH
200
+ return unless @seen.add?(name) # loop guard (§11.1)
201
+
202
+ if @spent > CEILING
203
+ @capped = true
204
+ return
205
+ end
206
+
207
+ txts = @resolver.call(name)
208
+ if txts.nil?
209
+ @partial = true
210
+ return
211
+ end
212
+
213
+ records = Array(txts).map { |t| Record.normalize_txt(t) }.select { |t| Record.spf_record?(t) }
214
+ # §5.2 — an include: whose target publishes no SPF record is a permerror
215
+ # for the whole evaluation, not a term that quietly does nothing.
216
+ return @targets_without_spf << name if records.empty?
217
+ # §4.5 — two records anywhere in the chain permerror the whole thing.
218
+ return @duplicated_in_chain << name if records.size > 1
219
+
220
+ walk(records.first, depth + 1)
221
+ end
222
+
223
+ def walk(record, depth)
224
+ parsed = Record.parse_terms(record, term_class: @term_class)
225
+ terminal = parsed.any?(&:all?)
226
+
227
+ parsed.each do |term|
228
+ break if term.all? # nothing after `all` is ever evaluated
229
+ next if term.redirect? && terminal # §6.1
230
+
231
+ charge(term, depth: depth)
232
+ end
233
+ end
234
+ end
235
+ end
236
+ 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.1"
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.1
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: