DKIM / SPF / DMARC Builder & Checker — Ruby source
Build the three email-authentication DNS records — SPF, DKIM, and DMARC — as ready-to-paste TXT values, or look up a domain's live records over DNS-over-HTTPS. Import existing records, catch publishing mistakes, share via URL. Entirely client-side.
This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# DKIM / SPF / DMARC — email authentication record parsing and lookup.
#
# Language: Ruby (3.x, standard library only — net/http + json + uri for the
# DNS-over-HTTPS lookup, base64 for the DKIM key; the parsers
# themselves need nothing)
# Source: CosmoDev polyglot showcase port of the DKIM/SPF/DMARC Checker tool,
# ported from src/lib/dkim-spf-dmarc.ts (the canonical TypeScript
# implementation).
# License: display source — part of CosmoDev's polyglot tool pages.
#
# Two layers, mirroring the TS reference:
# - Pure parsers (parse_spf / parse_dkim / parse_dmarc) — deterministic,
# never raise, testable without network.
# - Check functions (check_spf / check_dkim / check_dmarc) — one
# DNS-over-HTTPS request to Cloudflare's public resolver, then a pure parse.
# CosmoDev runs no backend for this tool.
#
# Specs: SPF RFC 7208, DKIM RFC 6376, DMARC RFC 7489.
#
# The lookup path treats the resolver as untrusted input: the query name is
# built only from a normalized, DOMAIN_RE-validated host and a selector that
# passed is_valid_selector?, then percent-encoded — so no caller string can
# reshape the request URL. Records come back as data and are only ever parsed,
# never evaluated.
require 'base64'
require 'json'
require 'net/http'
require 'uri'
module DkimSpfDmarc
# --- types -----------------------------------------------------------------
SPFMechanism = Struct.new(:qualifier, :kind, :value, keyword_init: true)
SPFRecord = Struct.new(:valid, :version, :mechanisms, :redirect, :exp,
:lookup_count, :record_count, :warnings,
keyword_init: true)
DKIMRecord = Struct.new(:valid, :version, :key_type, :public_key, :key_bits,
:hashes, :services, :flags, :warnings,
keyword_init: true)
DMARCRecord = Struct.new(:valid, :policy, :subdomain_policy, :aggregate_uris,
:forensic_uris, :percent, :dkim_alignment,
:spf_alignment, :warnings, keyword_init: true)
# status is :pass | :warn | :fail
CheckResult = Struct.new(:status, :found, :record, :message, keyword_init: true)
# --- shared helpers --------------------------------------------------------
DOMAIN_RE = /\A(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}\z/
SELECTOR_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
SPF_LOOKUP_KINDS = %w[include a mx exists ptr].freeze
SPF_KINDS = %w[all include a mx ip4 ip6 exists ptr].freeze
DMARC_POLICIES = %w[none quarantine reject].freeze
DOH_ENDPOINT = 'https://cloudflare-dns.com/dns-query'
module_function
# Strip a leading scheme, userinfo, path, query, port, and trailing dot from
# user input.
def normalize_domain(raw)
s = raw.to_s.strip
s = s.sub(%r{\A[a-z][a-z0-9+.\-]*://}i, '') # scheme://
s = s.sub(/\Amailto:/i, '') # mailto:user@domain
s = s[(s.rindex('@') + 1)..] if s.include?('@') # keep host of user@host
s = s.split('/').first.to_s # drop path
s = s.split('?').first.to_s # drop query
s = s.split(':').first.to_s # drop port
s = s.sub(/\.+\z/, '') # trailing dot(s)
s.downcase
end
# True when the string looks like a plausible multi-label domain.
def domain_like?(domain)
DOMAIN_RE.match?(domain) && domain.length <= 253
end
# True when the selector is a safe single DNS label chain — no spaces, no
# traversal. This is the guard that keeps a crafted selector from walking out
# of the _domainkey subtree in the query name.
def valid_selector?(selector)
s = selector.to_s.strip
!s.empty? && s.length <= 100 && SELECTOR_RE.match?(s) && !s.include?('..')
end
# --- SPF — RFC 7208 --------------------------------------------------------
# Parse one or more (newline-joined) TXT record strings for SPF. Pure.
def parse_spf(txt)
lines = txt.to_s.split("\n").map { |l| unquote(l.strip) }.reject(&:empty?)
spf_lines = lines.select { |l| /\Av=spf1(?:\s|\z)/i.match?(l) }
warnings = []
if spf_lines.empty?
return SPFRecord.new(valid: false, mechanisms: [], lookup_count: 0, record_count: 0,
warnings: ['No v=spf1 record found in the supplied text.'])
end
if spf_lines.length > 1
warnings << "#{spf_lines.length} SPF records found — RFC 7208 allows exactly one. " \
'Receivers treat this as a permanent error and ignore SPF for the whole domain.'
end
terms = spf_lines[0].split(/\s+/)
version = terms[0]
mechanisms = []
redirect = nil
exp = nil
terms.drop(1).each do |term|
# Modifiers use '=': redirect= and exp=
if (modifier = /\A([a-z][a-z0-9-]*)=(.*)\z/i.match(term))
case modifier[1].downcase
when 'redirect' then redirect = modifier[2]
when 'exp' then exp = modifier[2]
else warnings << %(Unknown modifier "#{term}" ignored.)
end
next
end
m = /\A([+\-~?])?([a-z0-9]+)(?::(.*))?\z/i.match(term)
unless m
warnings << %(Unrecognized term "#{term}" ignored.)
next
end
qualifier = m[1] || '+'
kind = m[2].downcase
value = m[3]
unless SPF_KINDS.include?(kind)
warnings << %(Unknown mechanism "#{term}" ignored.)
next
end
if (value.nil? || value.empty?) && %w[include exists].include?(kind)
warnings << %(Mechanism "#{term}" is missing its required value.)
next
end
mechanisms << SPFMechanism.new(qualifier: qualifier, kind: kind, value: value)
end
lookup_count = mechanisms.count { |mech| SPF_LOOKUP_KINDS.include?(mech.kind) }
lookup_count += 1 if redirect
all = mechanisms.find { |mech| mech.kind == 'all' }
if all.nil? && redirect.nil?
warnings << 'No "all" mechanism and no "redirect=" — unmatched senders get a Neutral ' \
'result, so anyone can still send mail that looks like this domain.'
end
if all && all.qualifier == '+'
warnings << '"+all" explicitly allows every host on the internet to send mail as this ' \
'domain — this defeats SPF entirely.'
elsif all && all.qualifier == '?'
warnings << '"?all" (Neutral) lets unmatched senders through with no protection. ' \
'Prefer "~all" or "-all".'
end
if mechanisms.any? { |mech| mech.kind == 'ptr' }
warnings << 'The "ptr" mechanism is deprecated (RFC 7208 §5.5) and should not be used.'
end
if redirect && all
warnings << 'A "redirect=" modifier is ignored when an "all" mechanism is present.'
end
if lookup_count > 10
warnings << "#{lookup_count} DNS-lookup mechanisms — RFC 7208 §4.6.4 caps SPF at 10. " \
'Receivers that hit the cap return permerror and ignore the record.'
end
SPFRecord.new(valid: true, version: version, mechanisms: mechanisms, redirect: redirect,
exp: exp, lookup_count: lookup_count, record_count: spf_lines.length,
warnings: warnings)
end
# --- DKIM — RFC 6376 -------------------------------------------------------
# Parse a `<selector>._domainkey` TXT record. Pure.
def parse_dkim(txt)
warnings = []
tags = parse_tag_list(txt, warnings)
version = tags['v']
if version && version.upcase != 'DKIM1'
warnings << "Unusual version tag v=#{version} (expected DKIM1)."
end
key_type = tags.fetch('k', 'rsa')
p_tag = tags['p']
hashes = split_colon_list(tags['h'])
services = split_colon_list(tags['s'])
flags = split_colon_list(tags['t'])
record = DKIMRecord.new(valid: true, key_type: key_type, hashes: hashes,
services: services, flags: flags, warnings: warnings)
record.version = version.upcase if version
if p_tag.nil?
record.valid = false
warnings << 'No p= tag — this record is not a usable DKIM key.'
return record
end
if p_tag.empty?
warnings << 'p= is empty — the key is revoked. Receivers will treat mail signed with ' \
'this selector as unsigned.'
return record
end
decoded = decode_base64_lenient(p_tag)
if decoded.nil?
warnings << 'The p= value is not valid base64 — the key could not be read.'
return record
end
record.public_key = p_tag.gsub(/\s+/, '')
if key_type == 'rsa'
# SubjectPublicKeyInfo DER ≈ modulus bits/8 + ~24 bytes of ASN.1
# overhead. The estimate is close enough to classify 512/1024/2048/4096.
bits = [0, decoded.bytesize - 24].max * 8
record.key_bits = bits
if bits < 1024
warnings << "Weak RSA key (~#{bits} bits). Keys under 1024 bits are considered " \
'breakable; RFC 8301 discourages short keys.'
elsif bits < 2048
warnings << "RSA key of ~#{bits} bits works today but is below the recommended " \
'2048 bits (RFC 8301).'
end
end
if flags.include?('y')
warnings << 't=y — the key is in test mode: receivers must treat signatures as if unsigned.'
end
if flags.include?('s')
warnings << 't=s — strict domain matching: the key cannot be used for subdomain ' \
'signatures (informational).'
end
record
end
# --- DMARC — RFC 7489 ------------------------------------------------------
# Parse a `_dmarc` TXT record. Pure.
def parse_dmarc(txt)
warnings = []
tags = parse_tag_list(txt, warnings)
record = DMARCRecord.new(valid: true, aggregate_uris: [], forensic_uris: [],
warnings: warnings)
version = tags['v']
if version.nil? || version.empty?
record.valid = false
warnings << 'No v= tag — this is not a DMARC record.'
return record
end
if version.upcase != 'DMARC1'
record.valid = false
warnings << "Unknown version v=#{version} (expected DMARC1)."
return record
end
policy = tags['p']&.downcase
if policy.nil? || policy.empty?
record.valid = false
warnings << 'No p= policy tag — DMARC requires it.'
return record
end
unless DMARC_POLICIES.include?(policy)
record.valid = false
warnings << "Invalid policy p=#{policy} (expected none, quarantine, or reject)."
return record
end
record.policy = policy
sp = tags['sp']&.downcase
if sp && !sp.empty?
if DMARC_POLICIES.include?(sp)
record.subdomain_policy = sp
else
warnings << "Invalid sp=#{sp} ignored (expected none, quarantine, or reject)."
end
end
record.aggregate_uris = split_comma_list(tags['rua'])
record.forensic_uris = split_comma_list(tags['ruf'])
if (pct = tags['pct'])
n = integer_or_nil(pct)
if n.nil? || n.negative? || n > 100
warnings << "Invalid pct=#{pct} ignored (must be 0-100)."
else
record.percent = n
end
end
if (adkim = tags['adkim']) && !adkim.empty?
if %w[r s].include?(adkim)
record.dkim_alignment = adkim
else
warnings << "Invalid adkim=#{adkim} ignored (expected r or s)."
end
end
if (aspf = tags['aspf']) && !aspf.empty?
if %w[r s].include?(aspf)
record.spf_alignment = aspf
else
warnings << "Invalid aspf=#{aspf} ignored (expected r or s)."
end
end
# Policy guidance
if record.policy == 'none'
warnings << 'p=none is monitor-only — no mail is quarantined or rejected, but you still ' \
'need SPF/DKIM to pass for reports to be useful.'
end
if record.aggregate_uris.empty?
warnings << 'No rua= address — without aggregate reports you cannot see who is failing ' \
'authentication. Add rua=mailto:reports@example.com.'
elsif !record.forensic_uris.empty?
warnings << 'ruf= (forensic reports) is supported by few receivers and may leak message ' \
'content to the report address (informational).'
end
if record.percent && record.percent < 100 && record.policy != 'none'
warnings << "pct=#{record.percent} applies the policy to only #{record.percent}% of " \
"mail — the other #{100 - record.percent}% is unaffected."
end
record
end
# --- DNS-over-HTTPS lookup -------------------------------------------------
# Look up and evaluate a domain's SPF record.
def check_spf(domain)
host = normalize_domain(domain)
return invalid_domain_result unless domain_like?(host)
txts = query_txt(host)
record = parse_spf(txts.join("\n"))
unless record.valid
return CheckResult.new(status: :fail, found: false,
message: "No SPF record found for #{host}. Receivers cannot " \
'verify which servers may send mail for it.')
end
status = record.warnings.empty? ? :pass : :warn
CheckResult.new(status: status, found: true, record: record,
message: status == :pass ? 'SPF record found and looks healthy.' : nil)
rescue StandardError => e
CheckResult.new(status: :fail, found: false, message: e.message)
end
# Look up and evaluate a domain's DKIM public key for one selector.
def check_dkim(domain, selector)
host = normalize_domain(domain)
return invalid_domain_result unless domain_like?(host)
sel = selector.to_s.strip.downcase
unless valid_selector?(sel)
return CheckResult.new(status: :fail, found: false,
message: 'Enter a valid selector (letters, digits, dots, ' \
'hyphens, underscores).')
end
txts = query_txt("#{sel}._domainkey.#{host}")
record = parse_dkim(txts.join("\n"))
unless record.valid
return CheckResult.new(status: :fail, found: false,
message: "No DKIM record found at #{sel}._domainkey.#{host}. " \
'Try another selector — only one is checked per lookup.')
end
revoked = record.warnings.any? { |w| w.include?('revoked') }
status = if revoked then :fail
elsif record.warnings.empty? then :pass
else :warn
end
CheckResult.new(status: status, found: true, record: record)
rescue StandardError => e
CheckResult.new(status: :fail, found: false, message: e.message)
end
# Look up and evaluate a domain's DMARC policy.
def check_dmarc(domain)
host = normalize_domain(domain)
return invalid_domain_result unless domain_like?(host)
txts = query_txt("_dmarc.#{host}")
record = parse_dmarc(txts.join("\n"))
unless record.valid
return CheckResult.new(status: :fail, found: false,
message: "No DMARC record found at _dmarc.#{host}. Receivers " \
'have no policy to apply when SPF or DKIM fails.')
end
status = record.warnings.empty? ? :pass : :warn
CheckResult.new(status: status, found: true, record: record)
rescue StandardError => e
CheckResult.new(status: :fail, found: false, message: e.message)
end
# --- internals -------------------------------------------------------------
def invalid_domain_result
CheckResult.new(status: :fail, found: false,
message: 'Enter a valid domain, e.g. example.com.')
end
# Query TXT records for a name via Cloudflare's DoH JSON API. Returns
# unquoted strings. `name` is always caller-validated before it gets here,
# and is percent-encoded on the way into the query string regardless.
def query_txt(name)
uri = URI.parse(DOH_ENDPOINT)
uri.query = URI.encode_www_form(name: name, type: 'TXT')
response = Net::HTTP.get_response(uri, 'Accept' => 'application/dns-json')
unless response.is_a?(Net::HTTPSuccess)
raise "DNS resolver responded with HTTP #{response.code}."
end
json = JSON.parse(response.body)
return [] if json['Status'] == 3 # NXDOMAIN — no such domain
raise "DNS query failed with status #{json['Status']}." unless json['Status'] == 0
Array(json['Answer'])
.select { |answer| answer['type'] == 16 }
# multi-chunk TXT arrives as "part1" "part2"
.map { |answer| unquote(answer['data'].to_s).gsub('" "', '') }
rescue JSON::ParserError
raise 'DNS resolver returned a malformed response.'
end
# Split a `k=v; k=v` tag list into a hash, recording malformed terms.
# Shared by the DKIM and DMARC parsers, which use the identical grammar.
def parse_tag_list(txt, warnings)
tags = {}
txt.to_s.split(';').each do |part|
term = unquote(part.strip).strip
next if term.empty?
eq = term.index('=')
if eq.nil? || eq.zero?
warnings << %(Malformed tag "#{term}" ignored.)
next
end
tags[term[0, eq].strip.downcase] = term[(eq + 1)..].strip
end
tags
end
# Strip one layer of surrounding double quotes, as TXT records carry.
def unquote(value)
value.to_s.sub(/\A"(.*)"\z/m, '\1')
end
def split_colon_list(value)
value.to_s.split(':').map(&:strip).reject(&:empty?)
end
def split_comma_list(value)
value.to_s.split(',').map(&:strip).reject(&:empty?)
end
# Strict integer parse — "50abc" and "1e2" are rejected, matching the TS
# Number.isInteger check rather than Ruby's lenient String#to_i.
def integer_or_nil(value)
Integer(value, 10)
rescue ArgumentError, TypeError
nil
end
# Decode base64 without raising on bad input. Missing padding is added first;
# the DKIM p= tag is frequently published unpadded.
def decode_base64_lenient(b64)
clean = b64.gsub(/\s+/, '')
padded = clean + ('=' * ((4 - (clean.length % 4)) % 4))
Base64.strict_decode64(padded)
rescue ArgumentError
nil
end
end
Also available in 9 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →