mailresolver

๐Ÿ” DNS resolution for mail, in Ruby.

Answers one question: what does DNS say, and if it didn't answer, why not?

mailresolver is a small, injectable DNS client for mail libraries โ€” TXT, A/AAAA, MX, PTR, and CNAME lookups โ€” built on dnsruby.

Contents

Installation

Add this line to your application's Gemfile:

gem "mailresolver"

And run:

bundle install

Requires Ruby >= 3.4

Usage

require "mailresolver"

resolver = MailResolver::Resolver.new

resolver.txt("example.com")           # => ["v=spf1 -all"]
resolver.a("example.com")             # => ["203.0.113.10"]
resolver.aaaa("example.com")          # => ["2001:db8::1"]
resolver.addresses("example.com")     # => ["203.0.113.10", "2001:db8::1"]
resolver.mx("example.com")            # => ["mx1.example.com", "mx2.example.com"], sorted by preference
resolver.ptr("203.0.113.10")          # => ["mail.example.com"]
resolver.cname("www.example.com")     # => ["example.com"]

addresses merges A and AAAA. A family the name doesn't publish contributes nothing to it rather than ending the lookup โ€” but a name that doesn't exist at all raises NoSuchName, and a nameserver that couldn't answer still raises, same as every other method here.

IPv6 addresses come back lower case: dnsruby renders them upper case, and RFC 5952 ยง4.3 asks for lower.

The Error Taxonomy

Every method raises one of three errors instead of guessing:

  • MailResolver::NotFound โ€” the server said there is nothing there: NXDOMAIN, or NOERROR with no records of the type asked for. NXDOMAIN specifically raises the subclass MailResolver::NoSuchName, for the callers โ€” implicit MX, for one โ€” to whom "the name doesn't exist" means something different from "the name has no records of this type"
  • MailResolver::ServerFailure โ€” the server didn't say: SERVFAIL, REFUSED, or a transport failure (a connect that failed, a socket dnsruby couldn't open, an address that wouldn't resolve)
  • MailResolver::Timeout โ€” no answer arrived in time

All of them descend from MailResolver::Error. Malformed input is the caller's bug, not a DNS answer, and raises ArgumentError instead โ€” ptr("not an ip"), for instance.

Why This Distinction Exists

NotFound and ServerFailure look interchangeable from a distance โ€” both mean "I don't have an answer for you" โ€” but they mean opposite things to the caller, and mail authentication is built on the difference.

SPF (RFC 7208) counts a void lookup โ€” a NotFound โ€” against its ten-lookup budget and keeps evaluating. It abandons evaluation entirely on a ServerFailure or a Timeout, returning temperror rather than pretending the record said nothing. Collapse the two, the way Resolv does, and a nameserver having a bad minute reads as a domain that designates no senders โ€” SPF fails a message that should have deferred, and a forged one passes for the same reason a legitimate one would have failed.

The same shape recurs in DKIM, DMARC, and MTA-STS: a missing record is a policy decision the domain made; a server that couldn't answer is not.

One Resolver, Shared

Build one MailResolver::Resolver and hand it to every mail library your app uses:

resolver = MailResolver::Resolver.new

MailAuth.authenticate(raw, ip: ip, resolver: resolver)
MtaSts.lookup(domain, known: nil, resolver: resolver)

dnsruby multiplexes concurrent queries over a single I/O thread, and can only do that if callers share one Dnsruby::Resolver. Handing each library its own MailResolver::Resolver โ€” rather than one instance shared across them โ€” buys nothing and costs an I/O thread apiece.

Configuration

MailResolver::Resolver.new(timeouts: [5, 3, 3])

timeouts is one attempt per element: the first element is how long the first packet gets before the first retry, dnsruby doubles the interval for each retry after that, and the sum is the hard deadline. The default sends packets at 0, 5, and 10 seconds and gives the whole query 11 seconds before raising Timeout.

DNSSEC validation and dnsruby's answer cache are both off, unconditionally. DNSSEC costs a round trip that most callers of this gem have no way to act on โ€” MTA-STS, for one, trusts the certificate on the policy host rather than the DNS answer. Caching is dnsruby's own concern to have off: its cache is process-global, so a nameserver that starts failing would otherwise keep serving its last good answer to every caller in the process.

Custom Resolvers

Anything that responds to txt, a, aaaa, addresses, mx, ptr, and cname โ€” raising the three errors above โ€” is a drop-in replacement. Tests typically stand in a hash for a zone rather than touching the network:

class FakeResolver
  def initialize(zone)
    @zone = zone
  end

  def txt(name)
    @zone.fetch(name) { raise MailResolver::NotFound, "no TXT records for #{name}" }
  end

  # ...
end

Testing

bundle install
rake

The suite stubs Dnsruby::Resolver's query path throughout.

History

View the changelog.

Contributing

Everyone is encouraged to help improve this project:

  • Report bugs
  • Fix bugs and submit pull requests
  • Write, clarify, or fix documentation
  • Suggest or add new features

License

MIT. See LICENSE.