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
- Usage
- The Error Taxonomy
- Why This Distinction Exists
- One Resolver, Shared
- Configuration
- Custom Resolvers
- Testing
- History
- Contributing
- License
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 subclassMailResolver::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.