mailauth
✉️ Email authentication for Ruby
mailauth verifies SPF, DKIM, DMARC, and ARC on received mail, and signs outgoing mail with DKIM. Pure Ruby, no dependencies beyond dnsruby.
It answers one question about a message: is it really from who it says it's from?
Features
- SPF verification (RFC 7208)
- DKIM verification (RFC 6376), RSA and Ed25519
- DKIM signing (RFC 6376), RSA and Ed25519, oversigning by default
- DMARC verification (RFC 7489), strict and relaxed alignment
- Authentication-Results header generation (RFC 8601)
- ARC verification (RFC 8617), reporting what each hop sealed and left to you to trust
- Injectable resolver, so tests run against a hash instead of a network
Not planned: MTA-STS (outbound TLS policy, not message auth), Received-SPF (Authentication-Results already covers it), %{p} (RFC 7208 discourages PTR).
Not a spam filter.
Getting Started
Add this line to your application's Gemfile:
gem "mailauth"Requires Ruby >= 3.3.4
Authentication
result = MailAuth.authenticate(raw_message, ip:, helo:, mail_from:, mta:, resolver:, now:)
# => #<MailAuth::Result spf:, dkim:, dmarc:, arc:, authentication_results:>- raw_message - the RFC 822 bytes as they arrived
- ip - IP address of the connecting client
- helo - optional, hostname from the HELO/EHLO command
-
mail_from - optional, envelope sender; empty means
postmaster@helois checked - mta - optional, your authserv-id, for the generated header
-
resolver - optional, defaults to
MailAuth::Resolver.new -
now - optional
Time, for signature expiry, defaults toTime.now
require "mailauth"
result = MailAuth.authenticate(raw_message,
ip: "209.85.220.41",
helo: "mail-sor-f41.google.com",
mail_from: "someone@gmail.com",
mta: "mx.example.com")
puts result.authentication_resultsmx.example.com;
spf=pass
(domain of someone@gmail.com designates 209.85.220.41 as permitted sender)
smtp.mailfrom=gmail.com;
dkim=pass header.d=gmail.com header.s=20251104 header.a=rsa-sha256;
dmarc=pass (p=NONE) header.from=gmail.comResults
result.spf.status # => "pass"
result.spf.domain # => "gmail.com"
result.dkim.status # => "pass" - the best of every signature on the message
result.dkim.signatures # => [#<MailAuth::Signature status: "pass", domain: "gmail.com", ...>]
result.dmarc.status # => "pass"
result.dmarc.policy # => "reject" - what the domain asked for
result.dmarc.alignment # => { dkim: true, spf: false }Every status is one of pass, fail, softfail, neutral, none, temperror, permerror - the RFC 8601 vocabulary as plain strings, so a verdict persists without translation.
status and policy stay separate. A DMARC fail under p=none and one under p=reject are the same failure; only the second is a reason to refuse the mail.
Individual Checks
Each protocol is usable on its own. SPF needs no message at all, so it can answer at RCPT TO time, before any data has arrived:
MailAuth::Spf.check(ip: "209.85.220.41", helo: "mail-sor-f41.google.com",
mail_from: "someone@gmail.com")
# => #<MailAuth::SpfResult status: "pass", domain: "gmail.com", lookups: 1, ...>
message = MailAuth::Message.new(raw_message)
MailAuth::Dkim.verify(message)
# => #<MailAuth::DkimResult signatures: [...]>
MailAuth::Dmarc.check(header_from: message.header_from_domain, spf: spf, dkim: dkim)
# => #<MailAuth::DmarcResult status: "pass", policy: "none", ...>
MailAuth::Arc.verify(message)
# => #<MailAuth::ArcResult status: "pass", sets: [...], ...>DMARC takes the SPF and DKIM results rather than recomputing them - alignment is a comparison, not a check of its own. ARC takes neither: a chain says what an earlier hop concluded, a separate question from what this one measured.
When It Doesn't Pass
Nothing raises. A verdict is always returned, and the interesting ones carry a comment:
result.dkim.signatures.each do |signature|
puts "#{signature.domain}: #{signature.status} #{signature.comment}"
end
# gmail.com: fail body hash did not verify
# example.com: temperror DNS failure resolving sel._domainkey.example.com-
fail- the check ran and the message did not pass it -
temperror- DNS didn't answer; the same message may well pass later, don't treat it as a failure -
permerror- the record or signature is broken; retrying changes nothing -
none- the domain published no policy, or the message carried no signature
Resolver
Every lookup goes through one object with five methods - txt, a, aaaa, mx, ptr:
MailAuth.authenticate(raw, ip: ip, resolver: MyCachingResolver.new)Raise NotFound for a void lookup (SPF counts these against a limit), Timeout or ServerFailure for no answer, which produces a temperror that stops evaluation. Collapsing those into empty arrays loses a distinction SPF spends much of its rulebook on.
The default resolver uses dnsruby rather than Resolv, which collapses SERVFAIL/REFUSED into the same empty answer as NXDOMAIN. It caches nothing and does not validate DNSSEC.
ARC
Forwarding breaks SPF, and mailing lists that rewrite a subject line break DKIM. An Authenticated Received Chain carries what each hop concluded before that happened, sealed against later editing:
result.arc.status # => "pass" - the chain is intact
result.arc.sealers # => ["lists.example.org"] - oldest hop first
result.arc.newest.claimed
# => { "spf" => "pass", "dkim" => "pass", "dmarc" => "pass" }An intact chain is not a reason to believe it. Sealing a chain over a message of one's own costs nothing, so a pass from a sealer you've never heard of means only that they signed what they signed. This library reports who sealed and what they asserted; deciding whose word counts is yours:
if result.arc.intact? && TRUSTED_SEALERS.include?(result.arc.newest.seal.domain)
result.arc.newest.claimed["dmarc"]
endFor the same reason a chain never touches the DMARC verdict. result.dmarc is what this hop measured; letting a chain speak for it would sell a pass for the price of a signature.
Signing
signed = MailAuth::Dkim.sign(raw_message,
domain: "example.com", selector: "mail",
key: OpenSSL::PKey.read(File.read("private.pem")))
# => the message with a DKIM-Signature field prependedThe algorithm comes from the key: an RSA key signs rsa-sha256, an Ed25519 key ed25519-sha256 (RFC 8463). Canonicalization is relaxed/relaxed.
- headers - names to sign; defaults to the RFC 6376 §5.4.1 set
-
oversign - names listed in
h=once more than they occur, so an instance added after signing breaks it. Defaults tofrom,to,cc,subject,date,reply-to,message-id. Pass[]to disable -
expires_in - seconds, writes
x=; omitted by default -
now - the signing instant for
t=, defaults toTime.now
MailAuth::Dkim::Signer#field returns just the DKIM-Signature field, for callers assembling messages themselves.
Testing
bundle install
rakeSPF runs against the RFC 7208 test suite - 200 official cases covering every mechanism, the lookup limits, and the error semantics. One case is skipped: p-macro-multiple, which needs PTR forward-confirmation this library doesn't do.
DKIM runs against the sample message in RFC 8463 Appendix A, which carries both an RSA and an Ed25519 signature over the same body. Signing is held to the same standard: both algorithms are deterministic, so signing the sample message with its published private keys must reproduce the published signatures byte for byte.
ARC runs against the ValiMail ARC test suite - 171 cases over chain validation, header structure, tag grammar, and key strength.
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.