Class: MythicBeasts::DNS::EmailAuth

Inherits:
Object
  • Object
show all
Defined in:
lib/mythic_beasts/dns/email_auth.rb

Overview

Validates SPF, DKIM, and DMARC DNS records for a domain

Uses the Mythic Beasts DNS API to fetch TXT records and validate email authentication configuration. Returns structured results rather than printing output, so callers can format as needed.

Examples:

Verify all email auth records

result = client.dns.verify_email_auth("example.com", dkim_selectors: ["google", "mailchimp"])
result.valid?      # => true/false
result.errors       # => ["No SPF record found"]
result.spf.record   # => "v=spf1 include:_spf.google.com ~all"

See Also:

Defined Under Namespace

Classes: CheckResult, Result

Constant Summary collapse

SPF_LOOKUP_MECHANISMS =
%w[include a mx ptr exists redirect].freeze
VALID_DMARC_POLICIES =
%w[none quarantine reject].freeze

Instance Method Summary collapse

Constructor Details

#initialize(dns, zone) ⇒ EmailAuth

Returns a new instance of EmailAuth.

Parameters:

  • dns (MythicBeasts::DNS) —

    the DNS client instance

  • zone (String) —

    the domain/zone to verify



40
41
42
43
# File 'lib/mythic_beasts/dns/email_auth.rb', line 40

def initialize(dns, zone)
  @dns = dns
  @zone = zone
end

Instance Method Details

#verify(dkim_selectors: []) ⇒ Result

Run all email authentication checks

Parameters:

  • dkim_selectors (Array<String>) (defaults to: []) —

    DKIM selectors to check (e.g., ["google", "mailchimp"])

Returns:

  • (Result) —

    aggregated result with per-check details



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/mythic_beasts/dns/email_auth.rb', line 49

def verify(dkim_selectors: [])
  spf_result = verify_spf
  dmarc_result = verify_dmarc
  dkim_results = dkim_selectors.map { |selector| [selector, verify_dkim(selector)] }.to_h

  all_errors = spf_result.errors + dmarc_result.errors +
    dkim_results.values.flat_map(&:errors)
  all_warnings = spf_result.warnings + dmarc_result.warnings +
    dkim_results.values.flat_map(&:warnings)

  Result.new(
    spf: spf_result,
    dmarc: dmarc_result,
    dkim: dkim_results,
    errors: all_errors,
    warnings: all_warnings
  )
end

#verify_dkim(selector) ⇒ CheckResult

Verify DKIM record for a specific selector

Fetches TXT records at selector._domainkey.zone and validates the presence of v=DKIM1 and a valid base64 public key.

Parameters:

  • selector (String) —

    the DKIM selector (e.g., "google", "mailchimp")

Returns:



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/mythic_beasts/dns/email_auth.rb', line 176

def verify_dkim(selector)
  errors = []
  warnings = []

  txt_records = fetch_txt_records("#{selector}._domainkey")

  if txt_records.empty?
    return CheckResult.new(record: nil, valid: false,
      errors: ["No DKIM record found for selector '#{selector}'"],
      warnings: [], details: {selector: selector})
  end

  dkim = txt_records.join

  unless dkim.match?(/v=DKIM1/i)
    return CheckResult.new(record: dkim, valid: false,
      errors: ["DKIM record doesn't contain 'v=DKIM1'"],
      warnings: warnings, details: {selector: selector})
  end

  key_match = dkim.match(/p=([A-Za-z0-9+\/=]+)/)
  unless key_match
    return CheckResult.new(record: dkim, valid: false,
      errors: ["DKIM record missing or invalid public key (p= tag)"],
      warnings: warnings, details: {selector: selector})
  end

  # Validate base64 encoding (without requiring the base64 gem)
  key = key_match[1]
  unless valid_base64?(key)
    errors << "DKIM public key is not valid base64"
  end

  CheckResult.new(
    record: dkim, valid: errors.empty?, errors: errors, warnings: warnings,
    details: {selector: selector, key_length: key.length}
  )
end

#verify_dmarc ⇒ CheckResult

Verify DMARC record for the zone

Fetches TXT records at _dmarc.zone and validates syntax, required p= tag, and recommended rua= tag.

Returns:



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/mythic_beasts/dns/email_auth.rb', line 123

def verify_dmarc
  errors = []
  warnings = []

  txt_records = fetch_txt_records("_dmarc")

  if txt_records.empty?
    return CheckResult.new(record: nil, valid: true,
      errors: [],
      warnings: ["No DMARC record found (recommended for email authentication)"],
      details: {})
  end

  if txt_records.length > 1
    return CheckResult.new(record: txt_records, valid: false,
      errors: ["Multiple DMARC records found"], warnings: [], details: {})
  end

  dmarc = txt_records.first

  unless dmarc.start_with?("v=DMARC1")
    return CheckResult.new(record: dmarc, valid: false,
      errors: ["DMARC record doesn't start with 'v=DMARC1'"],
      warnings: [], details: {})
  end

  tags = parse_dmarc_tags(dmarc)

  if tags["p"]
    unless VALID_DMARC_POLICIES.include?(tags["p"])
      errors << "Invalid DMARC policy: #{tags["p"]}"
    end
  else
    errors << "DMARC record missing required 'p' tag"
  end

  unless tags["rua"]
    warnings << "DMARC record missing 'rua' tag (no aggregate reports)"
  end

  CheckResult.new(
    record: dmarc, valid: errors.empty?, errors: errors, warnings: warnings,
    details: {policy: tags["p"], tags: tags}
  )
end

#verify_spf ⇒ CheckResult

Verify SPF record for the zone

Fetches TXT records at the zone root and filters for v=spf1 records. Validates against RFC 7208: single record, valid syntax, <=10 DNS lookups.

Returns:



74
75
76
77
78
79
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
106
107
108
109
110
111
112
113
114
115
# File 'lib/mythic_beasts/dns/email_auth.rb', line 74

def verify_spf
  errors = []
  warnings = []

  txt_records = fetch_txt_records("@")
  spf_records = txt_records.select { |r| r.start_with?("v=spf1") }

  if spf_records.empty?
    return CheckResult.new(record: nil, valid: false,
      errors: ["No SPF record found"], warnings: [], details: {})
  end

  if spf_records.length > 1
    return CheckResult.new(record: spf_records, valid: false,
      errors: ["Multiple SPF records found (RFC 7208 violation)"],
      warnings: [], details: {})
  end

  spf = spf_records.first

  # Validate syntax
  if spf.match?(/\ball\b/) && !spf.match?(/[~\-+?]all\s*$/)
    warnings << "SPF 'all' mechanism should be at the end"
  end

  if spf.include?(";")
    warnings << "SPF record contains semicolons (check if intentional)"
  end

  # Count DNS lookups
  lookup_count = count_spf_lookups(spf)
  if lookup_count > 10
    errors << "SPF has #{lookup_count} DNS lookups (max 10 allowed per RFC 7208)"
  elsif lookup_count > 8
    warnings << "SPF has #{lookup_count} DNS lookups (approaching limit of 10)"
  end

  CheckResult.new(
    record: spf, valid: errors.empty?, errors: errors, warnings: warnings,
    details: {lookup_count: lookup_count}
  )
end