Module: MailerToGo::SPF::Record

Defined in:
lib/mailertogo/spf/record.rb

Overview

Reading and normalising the raw text of an SPF record. Everything here is pure: no DNS, no state, no opinions about who you are.

Class Method Summary collapse

Class Method Details

.include_target(record) ⇒ Object

The name an SPF record's include: mechanism points at ("v=spf1 include:X ~all" → "X"), so a caller can pass the record it wants published and let us derive the mechanism from it. nil when the record has no include.



41
42
43
44
# File 'lib/mailertogo/spf/record.rb', line 41

def include_target(record)
  term = record.to_s.split(/\s+/).find { |t| t.downcase.start_with?("include:") }
  term&.split(":", 2)&.last
end

.ip_nets(terms) ⇒ Object

The ip4:/ip6: terms of a term list, as IPAddr networks. Unparseable literals are dropped rather than raised on: a customer's malformed term is their record's problem, not a reason for us to blow up.



49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/mailertogo/spf/record.rb', line 49

def ip_nets(terms)
  terms.filter_map do |term|
    t = strip_qualifier(term)
    next unless t.downcase.start_with?("ip4:", "ip6:")

    begin
      IPAddr.new(t.split(":", 2).last)
    rescue StandardError
      nil
    end
  end
end

.mechanism_of(term) ⇒ Object

The mechanism name of a term ("include:x" → "include", "ip4:1.2.3.4" → "ip4", "a/24" → "a"), or nil when the term is a modifier or junk.



74
75
76
# File 'lib/mailertogo/spf/record.rb', line 74

def mechanism_of(term)
  strip_qualifier(term)[%r{\A([a-z0-9]+)(?::|/|\z)}i, 1]&.downcase
end

.normalize_name(name) ⇒ Object

A hostname as SPF compares them: case-insensitive, root dot optional (RFC 7208 §4.3 — the domain-spec is a DNS name, and DNS names are compared case-insensitively).



15
16
17
# File 'lib/mailertogo/spf/record.rb', line 15

def normalize_name(name)
  name.to_s.strip.downcase.chomp(".")
end

.normalize_txt(txt) ⇒ Object

A TXT answer as a single string.

A TXT record is a sequence of character-strings, each capped at 255 octets (RFC 1035 §3.3.14), so a long SPF record arrives as adjacent quoted chunks — "v=spf1 include:_spf.mailer" "togo.net ~all". RFC 7208 §3.3 says to concatenate them with no separator. SPF terms never contain a quote, so joining on quote boundaries is safe.



26
27
28
# File 'lib/mailertogo/spf/record.rb', line 26

def normalize_txt(txt)
  txt.to_s.strip.gsub(/"\s*"/, "").delete('"').strip
end

.qualifier_of(term) ⇒ Object



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

def qualifier_of(term)
  term.to_s[/\A[+\-~?]/]
end

.spf_record?(txt) ⇒ Boolean

Is this TXT string an SPF record? RFC 7208 §4.5: the version section is exactly "v=spf1", matched case-insensitively, followed by a space or the end of the record. A TXT record starting "v=spf10" is not SPF.

Returns:

  • (Boolean)


33
34
35
# File 'lib/mailertogo/spf/record.rb', line 33

def spf_record?(txt)
  txt.to_s.match?(/\Av=spf1(\s|\z)/i)
end

.strip_qualifier(term) ⇒ Object

RFC 7208 §4.6.2 — a mechanism may carry a leading qualifier (+ - ~ ?); "+" is the default when it is absent.



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

def strip_qualifier(term)
  term.to_s.sub(/\A[+\-~?]/, "")
end

.terms(record) ⇒ Object

The terms of a record, without the leading "v=spf1".



79
80
81
# File 'lib/mailertogo/spf/record.rb', line 79

def terms(record)
  record.to_s.split(/\s+/).drop(1)
end