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,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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailerToGo
4
+ module SPF
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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: []