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.

Constant Summary collapse

ALL_TERM =

A term that ends evaluation: all, with its optional qualifier, plus any junk glued onto it. The junk is real and surprisingly common — records ending ~all;google-site-verification=… exist in the wild, where the ;-joined fragment is not a valid SPF term at all.

/\A([+\-~?])?all([^a-z0-9].*)?\z/i
MODIFIER_TERM =

A modifier (redirect=, exp=, or an unknown one) rather than a mechanism. Modifiers are position-independent (§4.6.1), so they survive the merge even when they trail the record's all.

/\A([a-z][a-z0-9\-_.]*)=/i

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.



72
73
74
75
76
77
78
79
80
81
82
# File 'lib/mailertogo/spf/merge_plan.rb', line 72

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

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.



67
68
69
70
# File 'lib/mailertogo/spf/merge_plan.rb', line 67

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



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

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