Skip to content

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 →