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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE +21 -0
- data/README.md +295 -0
- data/lib/mailertogo/spf/authorization.rb +390 -0
- data/lib/mailertogo/spf/merge_plan.rb +246 -0
- data/lib/mailertogo/spf/plan.rb +92 -0
- data/lib/mailertogo/spf/record.rb +84 -0
- data/lib/mailertogo/spf/resolver.rb +97 -0
- data/lib/mailertogo/spf/result.rb +72 -0
- data/lib/mailertogo/spf/sender.rb +53 -0
- data/lib/mailertogo/spf/version.rb +7 -0
- data/lib/mailertogo/spf.rb +133 -0
- metadata +67 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mailertogo/spf/record"
|
|
4
|
+
require "mailertogo/spf/authorization"
|
|
5
|
+
|
|
6
|
+
module MailerToGo
|
|
7
|
+
module SPF
|
|
8
|
+
# What to tell a domain owner to publish, given what is already at that name.
|
|
9
|
+
#
|
|
10
|
+
# action:
|
|
11
|
+
# :publish — nothing SPF-shaped is published at this name: hand them
|
|
12
|
+
# the standalone record. Also the fallback when DNS did not
|
|
13
|
+
# answer (resolved? == false), because guessing is worse.
|
|
14
|
+
# :merge — one record is already there and does not authorize the
|
|
15
|
+
# sender: they must REPLACE it with merged_record.
|
|
16
|
+
# :deduplicate — two or more v=spf1 records are already published (the
|
|
17
|
+
# RFC 7208 §3.2 violation): replace ALL of them with
|
|
18
|
+
# merged_record.
|
|
19
|
+
# :satisfied — a single record already reaches the sender. Nothing to say.
|
|
20
|
+
#
|
|
21
|
+
# over_lookup_limit is true only when the merged record was definitively
|
|
22
|
+
# measured past RFC 7208 §4.6.4's cap — never on an inconclusive DNS answer.
|
|
23
|
+
Plan = Struct.new(:action, :name, :record, :include_name, :existing_records,
|
|
24
|
+
:merged_record, :all_qualifier, :lookups, :lookup_limit,
|
|
25
|
+
:over_lookup_limit, :resolved, :notes, keyword_init: true) do
|
|
26
|
+
def publish? = action == :publish
|
|
27
|
+
def merge? = action == :merge
|
|
28
|
+
def deduplicate? = action == :deduplicate
|
|
29
|
+
def satisfied? = action == :satisfied
|
|
30
|
+
|
|
31
|
+
# Are we asking them to REPLACE an existing record rather than add one?
|
|
32
|
+
# The difference matters enormously in a UI: "add this TXT record" next to
|
|
33
|
+
# an existing SPF record is exactly the instruction that produces the
|
|
34
|
+
# two-record permerror in the first place.
|
|
35
|
+
def replacement? = merge? || deduplicate?
|
|
36
|
+
|
|
37
|
+
# Already in the RFC-violating two-record state right now.
|
|
38
|
+
def duplicated? = deduplicate?
|
|
39
|
+
|
|
40
|
+
def over_limit? = over_lookup_limit == true
|
|
41
|
+
|
|
42
|
+
def resolved? = resolved == true
|
|
43
|
+
|
|
44
|
+
# The line to actually put in front of them. Withheld when merging would
|
|
45
|
+
# blow the lookup budget: a record we know permerrors is worse than the
|
|
46
|
+
# standalone instruction plus an explanation of what has to go first.
|
|
47
|
+
def offered_record
|
|
48
|
+
return nil unless replacement?
|
|
49
|
+
|
|
50
|
+
over_limit? ? nil : merged_record
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Is this state something to ALERT them about, or just an instruction?
|
|
54
|
+
# Already-duplicated and over-the-cap are defects they are living with
|
|
55
|
+
# today; a plain merge is just telling them how to add the sender
|
|
56
|
+
# correctly the first time. A fact about the plan, not about the view —
|
|
57
|
+
# two surfaces must not have to agree on it independently.
|
|
58
|
+
def warning? = duplicated? || over_limit?
|
|
59
|
+
def severity = warning? ? :warning : :info
|
|
60
|
+
|
|
61
|
+
# ── What one row of a "publish these DNS records" table should show ──
|
|
62
|
+
#
|
|
63
|
+
# Asking the plan reconciles the row against the instruction in ONE place.
|
|
64
|
+
# A view doing it itself re-derives the decision to withhold
|
|
65
|
+
# (offered_record) that the plan has already made, and the two can drift.
|
|
66
|
+
|
|
67
|
+
# Does this row carry the merged replacement instead of its own value?
|
|
68
|
+
# Only the SPF row at the name we resolved, and only when a merged line is
|
|
69
|
+
# actually on offer (never when it is withheld for the lookup cap).
|
|
70
|
+
def replaces?(name:, value:)
|
|
71
|
+
offered = offered_record
|
|
72
|
+
return false if offered.nil? || offered.empty?
|
|
73
|
+
return false unless value.to_s.start_with?("v=spf1")
|
|
74
|
+
|
|
75
|
+
Record.normalize_name(name) == self.name
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def value_for(name:, value:)
|
|
79
|
+
replaces?(name: name, value: value) ? offered_record : value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The "no instruction beyond the standalone record" plan, so no caller has
|
|
83
|
+
# to nil-check before asking a question. It answers everything the way a
|
|
84
|
+
# real :publish plan does: nothing to replace, nothing to warn about.
|
|
85
|
+
def self.none(name: nil, record: nil)
|
|
86
|
+
new(action: :publish, name: name && Record.normalize_name(name), record: record,
|
|
87
|
+
existing_records: [], notes: [], resolved: false,
|
|
88
|
+
lookup_limit: Authorization::MAX_DNS_LOOKUPS, over_lookup_limit: false)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module MailerToGo
|
|
6
|
+
module SPF
|
|
7
|
+
# Reading and normalising the raw text of an SPF record. Everything here is
|
|
8
|
+
# pure: no DNS, no state, no opinions about who you are.
|
|
9
|
+
module Record
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# A hostname as SPF compares them: case-insensitive, root dot optional
|
|
13
|
+
# (RFC 7208 §4.3 — the domain-spec is a DNS name, and DNS names are
|
|
14
|
+
# compared case-insensitively).
|
|
15
|
+
def normalize_name(name)
|
|
16
|
+
name.to_s.strip.downcase.chomp(".")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A TXT answer as a single string.
|
|
20
|
+
#
|
|
21
|
+
# A TXT record is a sequence of character-strings, each capped at 255
|
|
22
|
+
# octets (RFC 1035 §3.3.14), so a long SPF record arrives as adjacent
|
|
23
|
+
# quoted chunks — `"v=spf1 include:_spf.mailer" "togo.net ~all"`. RFC 7208
|
|
24
|
+
# §3.3 says to concatenate them with no separator. SPF terms never contain
|
|
25
|
+
# a quote, so joining on quote boundaries is safe.
|
|
26
|
+
def normalize_txt(txt)
|
|
27
|
+
txt.to_s.strip.gsub(/"\s*"/, "").delete('"').strip
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Is this TXT string an SPF record? RFC 7208 §4.5: the version section is
|
|
31
|
+
# exactly "v=spf1", matched case-insensitively, followed by a space or the
|
|
32
|
+
# end of the record. A TXT record starting "v=spf10" is not SPF.
|
|
33
|
+
def spf_record?(txt)
|
|
34
|
+
txt.to_s.match?(/\Av=spf1(\s|\z)/i)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The name an SPF record's `include:` mechanism points at
|
|
38
|
+
# ("v=spf1 include:X ~all" → "X"), so a caller can pass the record it wants
|
|
39
|
+
# published and let us derive the mechanism from it. nil when the record
|
|
40
|
+
# has no include.
|
|
41
|
+
def include_target(record)
|
|
42
|
+
term = record.to_s.split(/\s+/).find { |t| t.downcase.start_with?("include:") }
|
|
43
|
+
term&.split(":", 2)&.last
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The ip4:/ip6: terms of a term list, as IPAddr networks. Unparseable
|
|
47
|
+
# literals are dropped rather than raised on: a customer's malformed term
|
|
48
|
+
# is their record's problem, not a reason for us to blow up.
|
|
49
|
+
def ip_nets(terms)
|
|
50
|
+
terms.filter_map do |term|
|
|
51
|
+
t = strip_qualifier(term)
|
|
52
|
+
next unless t.downcase.start_with?("ip4:", "ip6:")
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
IPAddr.new(t.split(":", 2).last)
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# RFC 7208 §4.6.2 — a mechanism may carry a leading qualifier
|
|
63
|
+
# (+ - ~ ?); "+" is the default when it is absent.
|
|
64
|
+
def strip_qualifier(term)
|
|
65
|
+
term.to_s.sub(/\A[+\-~?]/, "")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def qualifier_of(term)
|
|
69
|
+
term.to_s[/\A[+\-~?]/]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The mechanism name of a term ("include:x" → "include", "ip4:1.2.3.4" →
|
|
73
|
+
# "ip4", "a/24" → "a"), or nil when the term is a modifier or junk.
|
|
74
|
+
def mechanism_of(term)
|
|
75
|
+
strip_qualifier(term)[%r{\A([a-z0-9]+)(?::|/|\z)}i, 1]&.downcase
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The terms of a record, without the leading "v=spf1".
|
|
79
|
+
def terms(record)
|
|
80
|
+
record.to_s.split(/\s+/).drop(1)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "resolv"
|
|
4
|
+
require "mailertogo/spf/record"
|
|
5
|
+
|
|
6
|
+
module MailerToGo
|
|
7
|
+
module SPF
|
|
8
|
+
# The DNS seam.
|
|
9
|
+
#
|
|
10
|
+
# A resolver is anything that responds to `#call(name)` and returns:
|
|
11
|
+
#
|
|
12
|
+
# [String, …] — the TXT strings published at that name
|
|
13
|
+
# [] — the name exists but has no TXT (or does not exist at all):
|
|
14
|
+
# a definitive "nothing here"
|
|
15
|
+
# nil — DNS did not answer (timeout, SERVFAIL, refused):
|
|
16
|
+
# inconclusive, verdict withheld
|
|
17
|
+
#
|
|
18
|
+
# That three-way return is the whole contract, and the nil is the important
|
|
19
|
+
# part: a resolver hiccup must never be reported as "this domain does not
|
|
20
|
+
# authorise you". Everything in this gem funnels a nil into an :unknown
|
|
21
|
+
# result rather than a :fail.
|
|
22
|
+
#
|
|
23
|
+
# A plain lambda satisfies the contract, which is how the test suite runs
|
|
24
|
+
# with no network at all:
|
|
25
|
+
#
|
|
26
|
+
# zone = { "example.com" => ["v=spf1 include:_spf.mailertogo.net ~all"] }
|
|
27
|
+
# MailerToGo::SPF.authorize("example.com", resolver: ->(n) { zone.fetch(n, []) })
|
|
28
|
+
#
|
|
29
|
+
# The default below uses Ruby's stdlib Resolv::DNS, so the gem has no runtime
|
|
30
|
+
# dependencies. If you already speak DNS-over-HTTPS (or hold a resolver pool,
|
|
31
|
+
# or want per-request caching), pass your own — see the README.
|
|
32
|
+
class Resolver
|
|
33
|
+
DEFAULT_TIMEOUT = 3
|
|
34
|
+
|
|
35
|
+
# timeout — seconds per nameserver attempt.
|
|
36
|
+
# nameservers — override the system resolvers, e.g. %w[1.1.1.1 8.8.8.8].
|
|
37
|
+
def initialize(timeout: DEFAULT_TIMEOUT, nameservers: nil)
|
|
38
|
+
@timeout = timeout
|
|
39
|
+
@nameservers = nameservers
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def call(name)
|
|
43
|
+
query = Record.normalize_name(name)
|
|
44
|
+
return [] if query.empty?
|
|
45
|
+
|
|
46
|
+
dns = @nameservers ? ::Resolv::DNS.new(nameserver: Array(@nameservers)) : ::Resolv::DNS.new
|
|
47
|
+
dns.timeouts = @timeout
|
|
48
|
+
begin
|
|
49
|
+
dns.getresources(query, ::Resolv::DNS::Resource::IN::TXT).map { |r| r.strings.join }
|
|
50
|
+
ensure
|
|
51
|
+
dns.close
|
|
52
|
+
end
|
|
53
|
+
rescue ::Resolv::ResolvError
|
|
54
|
+
# Resolv collapses NXDOMAIN and "no information" into one error, so this
|
|
55
|
+
# is the conservative reading: the name published nothing. A resolver
|
|
56
|
+
# that can see the rcode (DoH, for instance) should return nil for
|
|
57
|
+
# SERVFAIL and [] only for NXDOMAIN/NODATA — see the README.
|
|
58
|
+
[]
|
|
59
|
+
rescue StandardError
|
|
60
|
+
# Timeouts (Resolv::ResolvTimeout) and everything else unexpected:
|
|
61
|
+
# inconclusive, never a failure.
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Wraps any resolver in a small TTL cache.
|
|
67
|
+
#
|
|
68
|
+
# Resolving an SPF chain is up to ten serial round-trips, and this runs in
|
|
69
|
+
# request paths ("check my domain" pages) where that is not acceptable
|
|
70
|
+
# twice. Failures (nil) are deliberately NOT cached: caching a timeout would
|
|
71
|
+
# pin a healthy domain into :unknown for the whole TTL.
|
|
72
|
+
class CachingResolver
|
|
73
|
+
def initialize(resolver, ttl: 300, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
74
|
+
@resolver = resolver
|
|
75
|
+
@ttl = ttl
|
|
76
|
+
@clock = clock
|
|
77
|
+
@entries = {}
|
|
78
|
+
@mutex = Mutex.new
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def call(name)
|
|
82
|
+
key = Record.normalize_name(name)
|
|
83
|
+
now = @clock.call
|
|
84
|
+
cached = @mutex.synchronize { @entries[key] }
|
|
85
|
+
return cached[1] if cached && cached[0] > now
|
|
86
|
+
|
|
87
|
+
answer = @resolver.call(key)
|
|
88
|
+
@mutex.synchronize { @entries[key] = [now + @ttl, answer] } unless answer.nil?
|
|
89
|
+
answer
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def clear
|
|
93
|
+
@mutex.synchronize { @entries.clear }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailerToGo
|
|
4
|
+
module SPF
|
|
5
|
+
# The answer to "does this domain's published SPF authorise me?", shaped so
|
|
6
|
+
# callers ask it questions rather than pattern-match a hash.
|
|
7
|
+
#
|
|
8
|
+
# status — :pass | :pinned | :fail | :permerror | :unknown
|
|
9
|
+
#
|
|
10
|
+
# :pass — the sender is reachable from the record, directly or
|
|
11
|
+
# through its include chain: durable authorisation.
|
|
12
|
+
# :pinned — no include chain to the sender, but the record hardcodes
|
|
13
|
+
# the sender's current sending addresses. Mail passes SPF
|
|
14
|
+
# *today* and breaks silently the moment an address moves,
|
|
15
|
+
# so this is deliberately NOT :pass. See #pinned?.
|
|
16
|
+
# :fail — the chain resolved fine; the sender simply is not in it.
|
|
17
|
+
# :permerror — the record is broken (duplicate records, or past the
|
|
18
|
+
# §4.6.4 lookup cap). Receivers reject it, so nothing passes.
|
|
19
|
+
# :unknown — DNS did not answer. Verdict withheld, NOT a failure.
|
|
20
|
+
#
|
|
21
|
+
# The four-valued status exists so a resolver hiccup can never be mistaken
|
|
22
|
+
# for "this domain removed my record". Anything that gates a customer on SPF
|
|
23
|
+
# has to be able to tell those apart.
|
|
24
|
+
#
|
|
25
|
+
# reason narrows :permerror (:duplicate_records | :lookup_limit) and :pinned
|
|
26
|
+
# (:pinned_full | :pinned_partial).
|
|
27
|
+
#
|
|
28
|
+
# all_qualifier (:pass | :fail | :softfail | :neutral | nil) is what the
|
|
29
|
+
# record tells receivers to do with mail it does NOT authorise — the
|
|
30
|
+
# qualifier on its terminal `all` (RFC 7208 §5.1). It is deliberately a
|
|
31
|
+
# FIELD rather than a fifth status: the yes/no decision ("is my include
|
|
32
|
+
# published?") is identical either way, but a `~all` domain's unauthorised
|
|
33
|
+
# mail is merely marked while a `-all` domain's is rejected outright, and
|
|
34
|
+
# the customer's remedy is far more urgent in the second case. Callers that
|
|
35
|
+
# write to humans need to say which one they published.
|
|
36
|
+
#
|
|
37
|
+
# partial is true when part of the chain did not resolve, which makes
|
|
38
|
+
# `lookups` a FLOOR rather than the real cost. Harmless for the verdict — a
|
|
39
|
+
# match is a match — but it matters to anyone pricing a record against the
|
|
40
|
+
# §4.6.4 budget, who must not report "this fits" from a count it could not
|
|
41
|
+
# finish.
|
|
42
|
+
Result = Struct.new(:status, :reason, :detail, :matched, :lookups,
|
|
43
|
+
:all_qualifier, :partial, keyword_init: true) do
|
|
44
|
+
def pass? = status == :pass
|
|
45
|
+
def pinned? = status == :pinned
|
|
46
|
+
def permerror? = status == :permerror
|
|
47
|
+
def unknown? = status == :unknown
|
|
48
|
+
def failed? = %i[fail permerror].include?(status)
|
|
49
|
+
def partial? = partial == true
|
|
50
|
+
|
|
51
|
+
# Unauthorised, but under a `~all`: receivers mark the mail rather than
|
|
52
|
+
# rejecting it, so the domain is in a materially better position than a
|
|
53
|
+
# `-all` :fail. Same defect, different urgency.
|
|
54
|
+
def softfail? = status == :fail && all_qualifier == :softfail
|
|
55
|
+
|
|
56
|
+
# The record IS broken for receivers, but the sender's include is sitting
|
|
57
|
+
# in it. Gating callers use this to avoid treating "the customer's SPF has
|
|
58
|
+
# an unrelated RFC problem" as "the customer removed my record" — the
|
|
59
|
+
# remedy is completely different, and un-verifying them is wrong.
|
|
60
|
+
def permerror_with_sender_published? = permerror? && !matched.to_s.empty?
|
|
61
|
+
|
|
62
|
+
# Which defect to tell the customer about, or nil when the record is
|
|
63
|
+
# clean: :ip_pinned | :lookup_limit | :duplicate_records.
|
|
64
|
+
def defect
|
|
65
|
+
case status
|
|
66
|
+
when :pinned then :ip_pinned
|
|
67
|
+
when :permerror then reason == :lookup_limit ? :lookup_limit : :duplicate_records
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mailertogo/spf/record"
|
|
4
|
+
|
|
5
|
+
module MailerToGo
|
|
6
|
+
module SPF
|
|
7
|
+
# The sender you are asking about: "which names in a customer's SPF record
|
|
8
|
+
# mean *me*?"
|
|
9
|
+
#
|
|
10
|
+
# It is deliberately a set, not a single string. A sending service usually
|
|
11
|
+
# publishes a chain — an outer alias that customers are told to include, and
|
|
12
|
+
# a leaf that actually lists the addresses:
|
|
13
|
+
#
|
|
14
|
+
# mailertogo.net TXT v=spf1 include:_spf.mailertogo.net ~all
|
|
15
|
+
# _spf.mailertogo.net TXT v=spf1 ip4:… ip4:… ~all
|
|
16
|
+
#
|
|
17
|
+
# A customer who publishes `include:mailertogo.net` is authorised just as
|
|
18
|
+
# surely as one who publishes `include:_spf.mailertogo.net`; both must count,
|
|
19
|
+
# or you report a false failure against a domain that passes at every real
|
|
20
|
+
# receiver. Staging/regional spellings of the same zone belong here too.
|
|
21
|
+
class Sender
|
|
22
|
+
attr_reader :include_name, :names
|
|
23
|
+
|
|
24
|
+
# include_name — the mechanism you tell customers to publish.
|
|
25
|
+
# aliases — any other name that is equally you (outer alias, staging
|
|
26
|
+
# zone, a legacy name you still honour).
|
|
27
|
+
def initialize(include_name, aliases: [])
|
|
28
|
+
@include_name = Record.normalize_name(include_name)
|
|
29
|
+
raise ArgumentError, "include name is required" if @include_name.empty?
|
|
30
|
+
|
|
31
|
+
@names = ([@include_name] + Array(aliases).map { |n| Record.normalize_name(n) })
|
|
32
|
+
.reject(&:empty?)
|
|
33
|
+
.uniq
|
|
34
|
+
.freeze
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Does this name in someone's record mean us?
|
|
39
|
+
def covers?(name)
|
|
40
|
+
names.include?(Record.normalize_name(name))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The standalone record a domain with no SPF of its own should publish.
|
|
44
|
+
def record(all: "~all")
|
|
45
|
+
"v=spf1 include:#{include_name} #{all}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_s
|
|
49
|
+
include_name
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mailertogo/spf/version"
|
|
4
|
+
require "mailertogo/spf/record"
|
|
5
|
+
require "mailertogo/spf/sender"
|
|
6
|
+
require "mailertogo/spf/resolver"
|
|
7
|
+
require "mailertogo/spf/result"
|
|
8
|
+
require "mailertogo/spf/authorization"
|
|
9
|
+
require "mailertogo/spf/plan"
|
|
10
|
+
require "mailertogo/spf/merge_plan"
|
|
11
|
+
|
|
12
|
+
module MailerToGo
|
|
13
|
+
# An SPF engine that reads a record the way a receiving MTA does: it resolves
|
|
14
|
+
# the include chain, stops where the receiver stops, and counts DNS lookups
|
|
15
|
+
# against the RFC 7208 §4.6.4 cap.
|
|
16
|
+
#
|
|
17
|
+
# Two questions, two entry points:
|
|
18
|
+
#
|
|
19
|
+
# MailerToGo::SPF.authorize("example.com")
|
|
20
|
+
# → does this domain's published SPF authorize me?
|
|
21
|
+
#
|
|
22
|
+
# MailerToGo::SPF.merge_plan("example.com")
|
|
23
|
+
# → what should I tell them to publish, given what is already there?
|
|
24
|
+
#
|
|
25
|
+
# Both take `include:` (the mechanism you want authorized) and `aliases:`
|
|
26
|
+
# (other names that mean the same sender). Both default to MailerToGo's own
|
|
27
|
+
# names, so the zero-argument form is the useful one for MailerToGo customers
|
|
28
|
+
# and one keyword makes it work for anybody else:
|
|
29
|
+
#
|
|
30
|
+
# MailerToGo::SPF.authorize("example.com", include: "spf.example.net")
|
|
31
|
+
module SPF
|
|
32
|
+
# MailerToGo publishes an outer alias and a leaf. Customers are told to
|
|
33
|
+
# include the leaf, but either one authorizes us, so both count as "me".
|
|
34
|
+
DEFAULT_INCLUDE = "_spf.mailertogo.net"
|
|
35
|
+
DEFAULT_ALIASES = ["mailertogo.net"].freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Process-wide defaults. Everything here can also be passed per call.
|
|
39
|
+
#
|
|
40
|
+
# MailerToGo::SPF.configure do |c|
|
|
41
|
+
# c.include = "spf.example.net"
|
|
42
|
+
# c.aliases = ["example.net"]
|
|
43
|
+
# c.resolver = MailerToGo::SPF::CachingResolver.new(MailerToGo::SPF::Resolver.new)
|
|
44
|
+
# c.logger = Logger.new($stdout)
|
|
45
|
+
# end
|
|
46
|
+
attr_accessor :logger
|
|
47
|
+
attr_writer :include, :aliases, :resolver
|
|
48
|
+
|
|
49
|
+
def include_name
|
|
50
|
+
defined?(@include) && @include ? @include : DEFAULT_INCLUDE
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def aliases
|
|
54
|
+
defined?(@aliases) && @aliases ? @aliases : DEFAULT_ALIASES
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Resolving is I/O, so the default is built once and shared. Pass your own
|
|
58
|
+
# (any object responding to #call(name)) to change how DNS happens.
|
|
59
|
+
def resolver
|
|
60
|
+
@resolver ||= Resolver.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def configure
|
|
64
|
+
yield self
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Does <hostname>'s published SPF authorize the sender?
|
|
69
|
+
# Returns a Result (see result.rb) — never raises for a DNS failure.
|
|
70
|
+
#
|
|
71
|
+
# published: evaluates a supplied record as if it were published at
|
|
72
|
+
# <hostname>, which is how you price a record that does not exist yet.
|
|
73
|
+
def authorize(hostname, include: nil, aliases: nil, resolver: nil,
|
|
74
|
+
sender: nil, published: nil, logger: nil)
|
|
75
|
+
sender ||= sender_for(include || include_name, aliases)
|
|
76
|
+
raise ArgumentError, "no include name given or configured" if sender.nil?
|
|
77
|
+
|
|
78
|
+
Authorization.call(
|
|
79
|
+
hostname,
|
|
80
|
+
sender: sender,
|
|
81
|
+
resolver: resolver || self.resolver,
|
|
82
|
+
published: published,
|
|
83
|
+
logger: logger || self.logger
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# What should this domain publish, given what is already at that name?
|
|
88
|
+
# Returns a Plan (see plan.rb).
|
|
89
|
+
#
|
|
90
|
+
# record: the standalone record you would otherwise hand them; defaults to
|
|
91
|
+
# the sender's own `v=spf1 include:… ~all`.
|
|
92
|
+
def merge_plan(name, record: nil, include: nil, aliases: nil, resolver: nil,
|
|
93
|
+
sender: nil, authorization: nil, logger: nil)
|
|
94
|
+
# A record with no include mechanism gives us no identity to merge in —
|
|
95
|
+
# hand back a null plan rather than inventing one.
|
|
96
|
+
sender ||= sender_for(include || (record.nil? ? include_name : Record.include_target(record)), aliases)
|
|
97
|
+
return Plan.none(name: name, record: record) if sender.nil?
|
|
98
|
+
|
|
99
|
+
MergePlan.call(
|
|
100
|
+
name,
|
|
101
|
+
record: record || sender.record,
|
|
102
|
+
sender: sender,
|
|
103
|
+
resolver: resolver || self.resolver,
|
|
104
|
+
authorization: authorization,
|
|
105
|
+
logger: logger || self.logger
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The names that mean "me". Public because a caller that asks both
|
|
110
|
+
# questions about the same sender should build it once.
|
|
111
|
+
def sender(include: nil, aliases: nil)
|
|
112
|
+
sender_for(include || include_name, aliases)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# nil when there is no name to build a sender out of.
|
|
118
|
+
#
|
|
119
|
+
# The alias default is deliberately narrow: the configured aliases belong
|
|
120
|
+
# to the configured include, so asking about somebody else's include with
|
|
121
|
+
# no aliases of its own gets exactly that one name, not ours.
|
|
122
|
+
def sender_for(name, aliases)
|
|
123
|
+
name = name.to_s.strip
|
|
124
|
+
return nil if name.empty?
|
|
125
|
+
|
|
126
|
+
if aliases.nil?
|
|
127
|
+
aliases = Record.normalize_name(name) == Record.normalize_name(include_name) ? self.aliases : []
|
|
128
|
+
end
|
|
129
|
+
Sender.new(name, aliases: aliases)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mailertogo-spf
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- MailerToGo
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-15 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |
|
|
14
|
+
An SPF engine that follows include:/redirect= chains, stops at the first
|
|
15
|
+
matching mechanism, and counts DNS-querying terms against RFC 7208 §4.6.4's
|
|
16
|
+
cap of 10 — so it agrees with what real receivers do instead of
|
|
17
|
+
string-matching a token. It also plans the record a domain should publish:
|
|
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.
|
|
21
|
+
email:
|
|
22
|
+
- support@mailertogo.com
|
|
23
|
+
executables: []
|
|
24
|
+
extensions: []
|
|
25
|
+
extra_rdoc_files: []
|
|
26
|
+
files:
|
|
27
|
+
- CHANGELOG.md
|
|
28
|
+
- LICENSE
|
|
29
|
+
- README.md
|
|
30
|
+
- lib/mailertogo/spf.rb
|
|
31
|
+
- lib/mailertogo/spf/authorization.rb
|
|
32
|
+
- lib/mailertogo/spf/merge_plan.rb
|
|
33
|
+
- lib/mailertogo/spf/plan.rb
|
|
34
|
+
- lib/mailertogo/spf/record.rb
|
|
35
|
+
- lib/mailertogo/spf/resolver.rb
|
|
36
|
+
- lib/mailertogo/spf/result.rb
|
|
37
|
+
- lib/mailertogo/spf/sender.rb
|
|
38
|
+
- lib/mailertogo/spf/version.rb
|
|
39
|
+
homepage: https://github.com/aluminumio/mailertogo-spf
|
|
40
|
+
licenses:
|
|
41
|
+
- MIT
|
|
42
|
+
metadata:
|
|
43
|
+
source_code_uri: https://github.com/aluminumio/mailertogo-spf
|
|
44
|
+
changelog_uri: https://github.com/aluminumio/mailertogo-spf/blob/main/CHANGELOG.md
|
|
45
|
+
bug_tracker_uri: https://github.com/aluminumio/mailertogo-spf/issues
|
|
46
|
+
rubygems_mfa_required: 'true'
|
|
47
|
+
post_install_message:
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: 3.1.0
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 3.5.22
|
|
63
|
+
signing_key:
|
|
64
|
+
specification_version: 4
|
|
65
|
+
summary: Resolve SPF records the way a receiving MTA does, and plan the record a domain
|
|
66
|
+
should publish.
|
|
67
|
+
test_files: []
|