Class: MailerToGo::SPF::MergePlan

Inherits:
Object
  • Object
show all
Defined in:
lib/mailertogo/spf/merge_plan.rb

Overview

What a domain owner should PUBLISH for SPF, given what is ALREADY at that name in DNS.

Why this exists: the standard setup instruction — "add a TXT record: v=spf1 include:_spf.mailertogo.net ~all" — is correct only for a domain with no SPF at all. A domain that already has SPF (Google Workspace, a registrar's default, Microsoft 365, another ESP) and follows that instruction literally ends up publishing a SECOND v=spf1 record beside the first. RFC 7208 §3.2 says a domain MUST NOT have more than one, and §4.5 makes the pair a permerror — which does not merely fail to authorize the new sender, it breaks SPF for every sender the domain had. The customer's mail gets worse, and they did exactly what they were told.

The only correct action for a domain that already has SPF is to MERGE the new mechanism into the existing record. So: before rendering the instruction, look at the name. If SPF is already there, hand them ONE merged record to REPLACE it with.

Three things this is careful about, because each is a way to make a customer's mail worse rather than better:

* Their `all` qualifier is THEIR policy (§5.1) — `-all` rejects
unauthorized mail, `~all` only marks it. Carry it across verbatim
rather than quietly relaxing (or tightening) what receivers do with
mail nobody asked us about.
* The new include goes LAST, immediately before the terminal `all`,
because a mechanism after `all` is never evaluated (§5.1/§6.1).
* Merging costs DNS lookups, and §4.6.4 caps an evaluation at 10. A
record already near the cap can be pushed over it by one more include,
and a record over the cap permerrors for everyone. Measure the merged
record and say so, rather than handing over a line that breaks on
arrival.

Reads DNS, changes nothing. When DNS does not answer we fall back to the standalone instruction rather than guessing — better to under-help than to tell someone to replace a record we could not read.

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name, record:, sender:, resolver:, authorization: nil, logger: nil) ⇒ MergePlan

Returns a new instance of MergePlan.



68
69
70
71
72
73
74
75
76
77
78
# File 'lib/mailertogo/spf/merge_plan.rb', line 68

def initialize(name, record:, sender:, resolver:, authorization: nil, logger: nil)
  @name = Record.normalize_name(name)
  @record = record.to_s
  @sender = sender
  @include_name = sender.include_name
  @lookup = resolver
  @authorization = authorization
  @logger = logger
  @notes = []
  @all_qualifier = nil
end

Class Method Details

.call(name, record:, sender:, resolver:, authorization: nil, logger: nil) ⇒ Object

What a term IS — is it the terminal all (with whatever junk is glued onto it), is it a modifier, does it name the sender — is asked of Term rather than re-matched here. There was a second, informal copy of that knowledge in this file; two readings of the same term is exactly how a record ending ~all;google-site-verification=… ends up merged one way and described another.

name — the DNS name the record goes at. record — the standalone record you would otherwise have told them to publish, e.g. Sender#record. It is what a :publish plan hands back; the merged line is built from sender. sender — a Sender: the names that mean "me". resolver — anything responding to #call(name); see Resolver. authorization — an already-resolved Result for this same name, when the caller has one. Saves walking the chain twice; nil means we resolve it ourselves. logger — optional, anything responding to #warn.



63
64
65
66
# File 'lib/mailertogo/spf/merge_plan.rb', line 63

def self.call(name, record:, sender:, resolver:, authorization: nil, logger: nil)
  new(name, record: record, sender: sender, resolver: resolver,
            authorization: authorization, logger: logger).run
end

Instance Method Details

#runObject



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/mailertogo/spf/merge_plan.rb', line 80

def run
  txts = @lookup.call(@name)
  return plan(:publish, resolved: false) if txts.nil? # DNS did not answer

  records = Array(txts).map { |t| Record.normalize_txt(t) }.select { |t| Record.spf_record?(t) }
  return plan(:publish) if records.empty?

  # A single record that already reaches the sender — directly or through
  # an outer alias — is healthy. Never rewrite a working record.
  return plan(:satisfied, existing_records: records) if records.one? && authorized?

  merged = merge(records)

  # Nothing of ours to add (a single record that already names the sender
  # but has an unrelated defect of its own — that is a different warning,
  # not a merge instruction).
  return plan(:satisfied, existing_records: records) if records.one? && same_terms?(merged, records.first)

  budget = measure(merged)
  plan(records.size > 1 ? :deduplicate : :merge,
       existing_records: records,
       merged_record: merged,
       all_qualifier: @all_qualifier,
       lookups: budget[:lookups],
       over_lookup_limit: budget[:over_limit])
end