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.
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "mailertogo/spf/record"
5
+ require "mailertogo/spf/result"
6
+ require "mailertogo/spf/sender"
7
+
8
+ module MailerToGo
9
+ module SPF
10
+ # Answers one question the way a receiving MTA would: does <hostname>'s
11
+ # published SPF actually authorize this sender?
12
+ #
13
+ # Why resolve at all, instead of string-matching the record? Because SPF is
14
+ # a TREE, not a string. The obvious implementation — "does the apex record
15
+ # contain the literal token `include:_spf.mailertogo.net`" — reports a false
16
+ # failure against every customer who publishes an outer alias:
17
+ #
18
+ # example.com TXT v=spf1 include:spf.hosting.example include:mailertogo.net ~all
19
+ # mailertogo.net TXT v=spf1 include:_spf.mailertogo.net ~all
20
+ # _spf.mailertogo.net TXT v=spf1 ip4:… ip4:… ~all
21
+ #
22
+ # That record passes SPF at every real receiver. A literal match calls it a
23
+ # failure, and anything gated on the answer — verification state, drift
24
+ # alerts, the ability to send — goes wrong with it. So we RESOLVE the chain:
25
+ # follow `include:` (and the `redirect=` modifier) until we reach a name the
26
+ # sender owns, or an ip4/ip6 mechanism covering the sender's addresses.
27
+ #
28
+ # RFC 7208 §4.6.4 caps an evaluation at 10 DNS-querying mechanisms; past
29
+ # that a receiver returns PERMERROR and the record does NOT pass. We enforce
30
+ # the same cap rather than silently passing a record real receivers reject —
31
+ # and it doubles as the bound that stops a hostile record walking us into an
32
+ # unbounded crawl.
33
+ #
34
+ # The cap counts the terms an evaluation actually EVALUATES, and §4.6.2 ends
35
+ # the evaluation at the first mechanism that MATCHES — so we walk terms in
36
+ # record order and stop at the match, exactly where a receiver stops.
37
+ # Counting the whole tree instead reports a false permerror for the very
38
+ # common record whose match lands on term 10 of an 11-term record: inside
39
+ # budget, and passing at every real receiver.
40
+ class Authorization
41
+ # RFC 7208 §4.6.4 — mechanisms that cost a DNS query (include/a/mx/ptr/
42
+ # exists) plus the redirect modifier, summed over the terms EVALUATED
43
+ # before the match. "MUST limit ... to 10" makes exactly 10 legal:
44
+ # 10 passes, 11 permerrors.
45
+ MAX_DNS_LOOKUPS = 10
46
+
47
+ # Belt-and-suspenders against a pathological tree; the lookup budget above
48
+ # is the real bound, this just stops runaway recursion on a wide shallow
49
+ # record.
50
+ MAX_DEPTH = 10
51
+
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
58
+
59
+ # hostname — the domain whose SPF we are reading.
60
+ # sender — a Sender: the names that mean "me".
61
+ # resolver — anything responding to #call(name); see Resolver.
62
+ # published — evaluate this record AS IF it were published at hostname,
63
+ # instead of whatever DNS says is there. The only way to ask
64
+ # "what would this cost a receiver?" of a line that is not
65
+ # published yet; MergePlan prices its merged record with it.
66
+ # Everything below the apex still resolves from real DNS.
67
+ # logger — optional, anything responding to #warn.
68
+ def self.call(hostname, sender:, resolver:, published: nil, logger: nil)
69
+ new(hostname, sender: sender, resolver: resolver, published: published, logger: logger).run
70
+ end
71
+
72
+ # The sending addresses a sender authorizes, read from its own leaf record
73
+ # — the same record `include:_spf.mailertogo.net` resolves to. Used to
74
+ # DETECT a customer who hardcoded those addresses (:pinned), not to bless
75
+ # it. Resolved separately from the customer's walk so it never costs them
76
+ # lookup budget.
77
+ def self.sending_nets(sender, resolver)
78
+ txts = resolver.call(sender.include_name)
79
+ record = Array(txts).map { |t| Record.normalize_txt(t) }.find { |t| Record.spf_record?(t) }
80
+ return [] if record.nil? || record.empty?
81
+
82
+ Record.ip_nets(Record.terms(record))
83
+ end
84
+
85
+ def initialize(hostname, sender:, resolver:, published: nil, logger: nil)
86
+ @host = Record.normalize_name(hostname)
87
+ @sender = sender
88
+ @lookup = resolver
89
+ @logger = logger
90
+ @published = published.to_s.empty? ? nil : published.to_s
91
+ @want = sender.include_name
92
+ @seen = Set.new([@host])
93
+ @lookups = 0
94
+ @dns_error = false
95
+ @permerror = nil
96
+ @matched = nil
97
+ @matched_directly = false
98
+ @match_at = nil
99
+ # [IPAddr, lookups spent before that term] — the second half is what
100
+ # lets us charge a pinned record only the lookups a receiver does
101
+ # before it stops.
102
+ @customer_nets = []
103
+ @ip_pinned = nil
104
+ @ip_coverage = nil
105
+ @ip_match_at = nil
106
+ @all_qualifier = nil
107
+ end
108
+
109
+ def run
110
+ records, err = apex_records
111
+ return unknown("DNS lookup for #{@host} failed") if err == :error
112
+
113
+ if records.size > 1
114
+ # RFC 7208 §4.5: more than one v=spf1 record is a PERMERROR — no
115
+ # evaluation happens at all. We still scan every one of them for the
116
+ # sender's include so the result can say "broken, but my record IS
117
+ # published" (see #matched).
118
+ records.each { |r| note_sender_names(Record.terms(r), 0) }
119
+ return permerror(:duplicate_records,
120
+ "#{@host} publishes more than one v=spf1 record — receivers " \
121
+ "return permerror (RFC 7208 §4.5) and no SPF passes for this domain")
122
+ end
123
+
124
+ record = records.first
125
+ return failure("No v=spf1 record on #{@host}") if record.nil?
126
+
127
+ walk(record, depth: 0, authoritative: true)
128
+ match_by_ip! if @match_at.nil?
129
+
130
+ # Over budget beats everything: receivers PERMERROR such a record, so it
131
+ # does not pass even when the sender's include is sitting right there in
132
+ # it. What counts is the budget spent up to the MATCH (see #budget_used),
133
+ # not the size of the tree — terms after the match are never evaluated
134
+ # by anyone.
135
+ if budget_used > MAX_DNS_LOOKUPS
136
+ return permerror(:lookup_limit,
137
+ "#{@host}'s SPF needs more than #{MAX_DNS_LOOKUPS} DNS lookups " \
138
+ "(RFC 7208 §4.6.4) — receivers return permerror and the record never passes")
139
+ end
140
+
141
+ return permerror(*@permerror) if @permerror
142
+ return pass if @match_at
143
+ return pinned if @ip_pinned
144
+ return unknown("SPF chain for #{@host} could not be fully resolved (DNS lookup failed)") if @dns_error
145
+
146
+ failure("#{@host}'s SPF does not authorize #{@want} — resolved the full include chain " \
147
+ "(#{pluralize(@lookups, "DNS lookup")}) and #{@want} is not in it#{all_clause}")
148
+ end
149
+
150
+ private
151
+
152
+ # A supplied record stands in for the apex answer (see .call's `published:`);
153
+ # its includes still resolve against live DNS below.
154
+ def apex_records
155
+ return spf_records_at(@host) if @published.nil?
156
+
157
+ [[Record.normalize_txt(@published)].select { |t| Record.spf_record?(t) }, nil]
158
+ end
159
+
160
+ # Runaway guard for the crawl itself. The verdict uses #budget_used.
161
+ def over_budget? = @lookups > MAX_DNS_LOOKUPS
162
+
163
+ # The number a receiver measures against the §4.6.4 cap: DNS-querying
164
+ # terms evaluated up to and including the one that matched (§4.6.2 ends
165
+ # evaluation there). Only when nothing matches does the whole walk count.
166
+ # A record whose match lands on term 10 of an 11-term record spends 10 and
167
+ # passes; counting every node in the tree would spend 11 and permerror it.
168
+ def budget_used = @match_at || @ip_match_at || @lookups
169
+
170
+ # Walk one record's terms IN ORDER, descending into includes/redirects,
171
+ # exactly as far as a receiver would: evaluation stops at the first
172
+ # mechanism that matches (@match_at), at `all` (which always matches), and
173
+ # at the lookup budget. Collects ip4/ip6 terms as we go so a record that
174
+ # authorizes the sender's addresses directly still matches.
175
+ #
176
+ # `authoritative` marks the records whose `all` decides the domain's
177
+ # answer — the apex and anything it redirect=s to, never an include's (an
178
+ # included record's `all` never leaves that include, §5.2).
179
+ def walk(record, depth:, authoritative: false)
180
+ terms = Record.terms(record)
181
+ note_sender_names(terms, depth)
182
+ redirect = nil
183
+
184
+ terms.each do |term|
185
+ break if @match_at # §4.6.2 — evaluation ends at the first matching mechanism
186
+
187
+ qualifier = Record.qualifier_of(term)
188
+ t = Record.strip_qualifier(term)
189
+
190
+ if (target = t[/\Aredirect=(.+)\z/i, 1])
191
+ # A modifier, not a mechanism: it is evaluated only after every
192
+ # mechanism has failed to match (§6.1), so hold it until the loop
193
+ # is done.
194
+ redirect = target
195
+ next
196
+ end
197
+
198
+ mechanism = Record.mechanism_of(t)
199
+ case mechanism
200
+ when "all"
201
+ # `all` matches everything, so nothing after it is ever evaluated
202
+ # and any redirect= in the same record is ignored outright (§6.1).
203
+ @all_qualifier ||= ALL_QUALIFIERS.fetch(qualifier || "+", :pass) if authoritative
204
+ # Leaves #walk, not just the loop: a pending redirect= must be
205
+ # abandoned too, which a `break` would not do.
206
+ return # rubocop:disable Lint/NonLocalExitFromIterator
207
+ when "ip4", "ip6"
208
+ @customer_nets.concat(Record.ip_nets([t]).map { |net| [net, @lookups] })
209
+ when *QUERYING_MECHANISMS
210
+ break if over_budget?
211
+
212
+ if mechanism == "include"
213
+ descend(t.split(":", 2).last, depth)
214
+ else
215
+ # a / mx / ptr / exists cost budget but cannot name the sender —
216
+ # a sending service publishes an include target, never an a/mx
217
+ # mechanism a customer would borrow.
218
+ @lookups += 1
219
+ end
220
+ end
221
+ end
222
+
223
+ return if redirect.nil? || @match_at || over_budget?
224
+
225
+ # Nothing matched: the redirect target's evaluation replaces this
226
+ # record's, its `all` included.
227
+ descend(redirect, depth, authoritative: authoritative)
228
+ end
229
+
230
+ # Free pass over a record's terms (no DNS) noting whether it names the
231
+ # sender. Done before the budget-limited expansion below so that a record
232
+ # which blows the 10-lookup cap STILL tells us the customer published the
233
+ # include — gating callers need that distinction so they do not un-verify
234
+ # a domain whose only sin is an over-long chain.
235
+ def note_sender_names(terms, depth)
236
+ terms.each do |term|
237
+ t = Record.strip_qualifier(term)
238
+ target = t[/\Ainclude:(.+)\z/i, 1] || t[/\Aredirect=(.+)\z/i, 1]
239
+ next if target.nil? || target.empty?
240
+
241
+ name = Record.normalize_name(target)
242
+ next unless @sender.covers?(name)
243
+ next unless @matched.nil?
244
+
245
+ @matched = name
246
+ @matched_directly = depth.zero?
247
+ end
248
+ end
249
+
250
+ def descend(target, depth, authoritative: false)
251
+ @lookups += 1
252
+ name = Record.normalize_name(target.to_s)
253
+ return if name.empty?
254
+
255
+ if @sender.covers?(name)
256
+ if @matched.nil?
257
+ @matched = name
258
+ @matched_directly = depth.zero?
259
+ end
260
+ # The mechanism that ended the evaluation, and the budget it had spent
261
+ # by then — the only count §4.6.4 measures.
262
+ @match_at = @lookups
263
+ return # no need to resolve the sender's own record; we know what it authorizes
264
+ end
265
+
266
+ # Macro expansion (%{i} etc., §7) is per-message and cannot be evaluated
267
+ # here; the lookup is counted, the branch is simply not followed.
268
+ return if name.include?("%")
269
+ return if depth >= MAX_DEPTH
270
+ return unless @seen.add?(name) # loop guard (RFC 7208 §11.1 include loops)
271
+ return if over_budget?
272
+
273
+ records, err = spf_records_at(name)
274
+ @dns_error = true if err == :error
275
+
276
+ if records.size > 1
277
+ # A duplicate-record PERMERROR anywhere in the chain permerrors the
278
+ # whole evaluation for the receiver, so it fails the domain too.
279
+ records.each { |r| note_sender_names(Record.terms(r), depth + 1) }
280
+ @permerror ||= [:duplicate_records,
281
+ "#{name} (included by #{@host}) publishes more than one v=spf1 record, " \
282
+ "which makes the whole evaluation permerror (RFC 7208 §4.5)",]
283
+ return
284
+ end
285
+
286
+ return if records.first.nil?
287
+
288
+ walk(records.first, depth: depth + 1, authoritative: authoritative)
289
+ end
290
+
291
+ # [records_array, error_or_nil] — error is :error when DNS itself failed.
292
+ # More than one record is left for the caller to classify (RFC 7208 §4.5).
293
+ def spf_records_at(name)
294
+ txts = @lookup.call(name)
295
+ return [[], :error] if txts.nil?
296
+
297
+ [Array(txts).map { |t| Record.normalize_txt(t) }.select { |t| Record.spf_record?(t) }, nil]
298
+ end
299
+
300
+ # A customer who lists the sender's addresses directly instead of
301
+ # including it is NOT equivalently authorized, and reporting that as a
302
+ # clean pass is a trap. Sending addresses move — relay nodes rotate,
303
+ # address ranges are added and retired — and the entire point of
304
+ # publishing an include target is that an include inherits those changes
305
+ # for free. A pinned record instead (a) silently stops authenticating the
306
+ # day an address moves, with no signal to anyone, and (b) keeps
307
+ # authorizing an address after the sender releases it, which may later
308
+ # belong to somebody else entirely. So: detect it, give it its own
309
+ # :pinned status, and tell them to switch.
310
+ def match_by_ip!
311
+ return if @customer_nets.empty?
312
+
313
+ ours = self.class.sending_nets(@sender, @lookup)
314
+ return if ours.empty?
315
+
316
+ # "Covered" = the customer's term authorizes one of the sender's ranges
317
+ # in full. "Touched" also counts a term that names only part of a range
318
+ # (one address out of a /24, say) — already-partial pinning, the most
319
+ # brittle shape of all, and the one most worth surfacing.
320
+ covered = ours.count { |o| @customer_nets.any? { |c, _at| covers?(c, o) } }
321
+ touched = ours.count { |o| @customer_nets.any? { |c, _at| covers?(c, o) || covers?(o, c) } }
322
+ return if touched.zero?
323
+
324
+ @ip_pinned = covered == ours.size ? :pinned_full : :pinned_partial
325
+ @ip_coverage = [touched, ours.size]
326
+ # An ip mechanism covering the sending address ends the receiver's
327
+ # evaluation right there (§4.6.2), so a pinned record is charged only
328
+ # the lookups spent BEFORE that term — a long chain sitting behind the
329
+ # pin never makes it permerror.
330
+ @ip_match_at = @customer_nets.filter_map do |c, at|
331
+ at if ours.any? { |o| covers?(c, o) || covers?(o, c) }
332
+ end.min
333
+ end
334
+
335
+ def covers?(outer, inner)
336
+ outer.include?(inner)
337
+ rescue StandardError
338
+ false # mismatched families (ip4 vs ip6) etc.
339
+ end
340
+
341
+ def pass
342
+ detail = if @matched_directly
343
+ "SPF includes #{@matched}"
344
+ else
345
+ "SPF reaches #{@matched} through its include chain (#{pluralize(@lookups, "DNS lookup")})"
346
+ end
347
+ result(:pass, detail: detail)
348
+ end
349
+
350
+ def pinned
351
+ touched, total = @ip_coverage
352
+ detail = if @ip_pinned == :pinned_full
353
+ "SPF lists #{@want}'s sending IP ranges directly (all #{total}) but never includes " \
354
+ "#{@want} — it authenticates today and stops silently the next time those IPs change"
355
+ else
356
+ "SPF lists #{@want}'s sending IPs directly and covers only #{touched} of the #{total} " \
357
+ "current ranges, and never includes #{@want} — mail from the rest already fails SPF"
358
+ end
359
+ # A pinned record's `matched` stays nil: the include is NOT published in it.
360
+ Result.new(status: :pinned, reason: @ip_pinned, detail: detail,
361
+ lookups: budget_used, all_qualifier: @all_qualifier, partial: @dns_error)
362
+ end
363
+
364
+ def failure(detail) = result(:fail, detail: detail)
365
+ def unknown(detail) = result(:unknown, detail: detail)
366
+ def permerror(reason, detail) = result(:permerror, detail: detail, reason: reason)
367
+
368
+ def result(status, detail:, reason: nil)
369
+ Result.new(status: status, reason: reason, detail: detail, matched: @matched,
370
+ lookups: budget_used, all_qualifier: @all_qualifier, partial: @dns_error)
371
+ end
372
+
373
+ # RFC 7208 §5.1: the terminal `all` decides what a receiver does with mail
374
+ # from a domain whose record does not authorize the sender. `-all` is an
375
+ # instruction to REJECT it; `~all` (much more common) only marks it. Same
376
+ # defect, materially different urgency — so say which one they published.
377
+ def all_clause
378
+ case @all_qualifier
379
+ when :fail then ". Its -all tells receivers to reject that mail outright"
380
+ when :softfail then ". Its ~all means receivers mark that mail rather than rejecting it"
381
+ else ""
382
+ end
383
+ end
384
+
385
+ def pluralize(count, word)
386
+ "#{count} #{word}#{"s" unless count == 1}"
387
+ end
388
+ end
389
+ end
390
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mailertogo/spf/record"
4
+ require "mailertogo/spf/plan"
5
+ require "mailertogo/spf/authorization"
6
+
7
+ module MailerToGo
8
+ module SPF
9
+ # What a domain owner should PUBLISH for SPF, given what is ALREADY at that
10
+ # name in DNS.
11
+ #
12
+ # Why this exists: the standard setup instruction — "add a TXT record:
13
+ # v=spf1 include:_spf.mailertogo.net ~all" — is correct only for a domain
14
+ # with no SPF at all. A domain that already has SPF (Google Workspace, a
15
+ # registrar's default, Microsoft 365, another ESP) and follows that
16
+ # instruction literally ends up publishing a SECOND v=spf1 record beside the
17
+ # first. RFC 7208 §3.2 says a domain MUST NOT have more than one, and §4.5
18
+ # makes the pair a permerror — which does not merely fail to authorize the
19
+ # new sender, it breaks SPF for every sender the domain had. The customer's
20
+ # mail gets worse, and they did exactly what they were told.
21
+ #
22
+ # The only correct action for a domain that already has SPF is to MERGE the
23
+ # new mechanism into the existing record. So: before rendering the
24
+ # instruction, look at the name. If SPF is already there, hand them ONE
25
+ # merged record to REPLACE it with.
26
+ #
27
+ # Three things this is careful about, because each is a way to make a
28
+ # customer's mail worse rather than better:
29
+ #
30
+ # * Their `all` qualifier is THEIR policy (§5.1) — `-all` rejects
31
+ # unauthorized mail, `~all` only marks it. Carry it across verbatim
32
+ # rather than quietly relaxing (or tightening) what receivers do with
33
+ # mail nobody asked us about.
34
+ # * The new include goes LAST, immediately before the terminal `all`,
35
+ # because a mechanism after `all` is never evaluated (§5.1/§6.1).
36
+ # * Merging costs DNS lookups, and §4.6.4 caps an evaluation at 10. A
37
+ # record already near the cap can be pushed over it by one more include,
38
+ # and a record over the cap permerrors for everyone. Measure the merged
39
+ # record and say so, rather than handing over a line that breaks on
40
+ # arrival.
41
+ #
42
+ # Reads DNS, changes nothing. When DNS does not answer we fall back to the
43
+ # standalone instruction rather than guessing — better to under-help than to
44
+ # tell someone to replace a record we could not read.
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
+
57
+ # name — the DNS name the record goes at.
58
+ # record — the standalone record you would otherwise have told them
59
+ # to publish, e.g. Sender#record. It is what a :publish
60
+ # plan hands back; the merged line is built from `sender`.
61
+ # sender — a Sender: the names that mean "me".
62
+ # resolver — anything responding to #call(name); see Resolver.
63
+ # authorization — an already-resolved Result for this same name, when the
64
+ # caller has one. Saves walking the chain twice; nil means
65
+ # we resolve it ourselves.
66
+ # logger — optional, anything responding to #warn.
67
+ def self.call(name, record:, sender:, resolver:, authorization: nil, logger: nil)
68
+ new(name, record: record, sender: sender, resolver: resolver,
69
+ authorization: authorization, logger: logger).run
70
+ end
71
+
72
+ def initialize(name, record:, sender:, resolver:, authorization: nil, logger: nil)
73
+ @name = Record.normalize_name(name)
74
+ @record = record.to_s
75
+ @sender = sender
76
+ @include_name = sender.include_name
77
+ @lookup = resolver
78
+ @authorization = authorization
79
+ @logger = logger
80
+ @notes = []
81
+ @all_qualifier = nil
82
+ end
83
+
84
+ def run
85
+ txts = @lookup.call(@name)
86
+ return plan(:publish, resolved: false) if txts.nil? # DNS did not answer
87
+
88
+ records = Array(txts).map { |t| Record.normalize_txt(t) }.select { |t| Record.spf_record?(t) }
89
+ return plan(:publish) if records.empty?
90
+
91
+ # A single record that already reaches the sender — directly or through
92
+ # an outer alias — is healthy. Never rewrite a working record.
93
+ return plan(:satisfied, existing_records: records) if records.one? && authorized?
94
+
95
+ merged = merge(records)
96
+
97
+ # Nothing of ours to add (a single record that already names the sender
98
+ # but has an unrelated defect of its own — that is a different warning,
99
+ # not a merge instruction).
100
+ return plan(:satisfied, existing_records: records) if records.one? && same_terms?(merged, records.first)
101
+
102
+ budget = measure(merged)
103
+ plan(records.size > 1 ? :deduplicate : :merge,
104
+ existing_records: records,
105
+ merged_record: merged,
106
+ all_qualifier: @all_qualifier,
107
+ lookups: budget[:lookups],
108
+ over_lookup_limit: budget[:over_limit])
109
+ end
110
+
111
+ private
112
+
113
+ # Does the currently-published record already authorize the sender? Uses
114
+ # the caller's result when it has one, so a diagnostics page does not walk
115
+ # the same chain twice.
116
+ def authorized?
117
+ result = @authorization ||
118
+ Authorization.call(@name, sender: @sender, resolver: @lookup, logger: @logger)
119
+ result.pass?
120
+ rescue StandardError => e
121
+ warn_failure("authorization check", e)
122
+ false
123
+ end
124
+
125
+ # Their mechanisms plus ours, in ONE record.
126
+ #
127
+ # Record order is deliberate: records that are NOT ours come first,
128
+ # because theirs is the policy record and its `all` is the one to
129
+ # preserve. Within a record, terms keep their published order — an SPF
130
+ # record's mechanisms are evaluated in order, and a customer may well have
131
+ # put a cheap ip4 first on purpose.
132
+ def merge(records)
133
+ theirs, ours = records.partition { |r| !ours?(r) }
134
+ mechanisms = []
135
+ modifiers = []
136
+ @all_qualifier = nil
137
+
138
+ (theirs + ours).each do |record|
139
+ seen_all = false
140
+
141
+ Record.terms(record).each do |term|
142
+ if (m = ALL_TERM.match(term))
143
+ seen_all = true
144
+ # First `all` across the ordered records wins — theirs, not ours.
145
+ # A bare `all` is `+all` (§4.6.2); spell it out so the merged line
146
+ # 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 " \
150
+ "and isn't a valid SPF term — publish it as its own TXT record if you still need it."
151
+ end
152
+ next
153
+ end
154
+
155
+ if MODIFIER_TERM.match?(term)
156
+ # redirect=/exp= are modifiers, not mechanisms: they apply to the
157
+ # whole record wherever they sit, so keep them (deduped by
158
+ # modifier name).
159
+ key = MODIFIER_TERM.match(term)[1].downcase
160
+ modifiers << term unless modifiers.any? { |t| MODIFIER_TERM.match(t)[1].casecmp?(key) }
161
+ next
162
+ end
163
+
164
+ if seen_all
165
+ # Unreachable in the published record (nothing after `all` is ever
166
+ # evaluated). Dropping it preserves the record's exact behavior;
167
+ # 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."
169
+ next
170
+ end
171
+
172
+ next if sender_term?(term)
173
+
174
+ mechanisms << term unless mechanisms.any? { |t| t.casecmp?(term) }
175
+ end
176
+ end
177
+
178
+ # Ours goes last — right before the terminal `all`, which matches
179
+ # everything and ends evaluation.
180
+ terms = mechanisms + ["include:#{@include_name}"]
181
+ terms << "#{@all_qualifier}all" if @all_qualifier
182
+ terms += modifiers
183
+ "v=spf1 #{terms.join(" ")}"
184
+ end
185
+
186
+ # The merged line's real cost to a receiver: evaluate it exactly as a
187
+ # published record would be evaluated, and let §4.6.4 apply. Our include
188
+ # sits last, so the budget reported is the budget the sender's own mail
189
+ # spends.
190
+ def measure(merged)
191
+ result = Authorization.call(@name, sender: @sender, resolver: @lookup,
192
+ published: merged, logger: @logger)
193
+ if result.unknown? || result.partial?
194
+ # A resolver hiccup mid-chain: the count is a floor, not the cost.
195
+ # Report nothing rather than a budget we could not finish measuring —
196
+ # and "it fits" would be the dangerous direction to get wrong.
197
+ { lookups: nil, over_limit: false }
198
+ else
199
+ { lookups: result.lookups, over_limit: result.permerror? && result.reason == :lookup_limit }
200
+ end
201
+ rescue StandardError => e
202
+ warn_failure("lookup budget", e)
203
+ { lookups: nil, over_limit: false }
204
+ end
205
+
206
+ # Does this record name the sender directly? Only a direct term counts
207
+ # here — this decides which record supplies the merged `all`, and a record
208
+ # that reaches the sender through somebody else's include is still THEIR
209
+ # policy record.
210
+ def ours?(record)
211
+ Record.terms(record).any? { |term| sender_term?(term) }
212
+ end
213
+
214
+ 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)
218
+ end
219
+
220
+ def same_terms?(a, b)
221
+ a.to_s.split(/\s+/).map(&:downcase) == b.to_s.split(/\s+/).map(&:downcase)
222
+ end
223
+
224
+ def warn_failure(what, error)
225
+ @logger&.warn("[MailerToGo::SPF] #{what} for #{@name} failed: #{error.message}")
226
+ end
227
+
228
+ def plan(action, **attrs)
229
+ Plan.new({
230
+ action: action,
231
+ name: @name,
232
+ record: @record,
233
+ include_name: @include_name,
234
+ existing_records: [],
235
+ merged_record: nil,
236
+ all_qualifier: nil,
237
+ lookups: nil,
238
+ lookup_limit: Authorization::MAX_DNS_LOOKUPS,
239
+ over_lookup_limit: false,
240
+ resolved: true,
241
+ notes: @notes,
242
+ }.merge(attrs))
243
+ end
244
+ end
245
+ end
246
+ end