mailreport

📧 DMARC report parsing for Ruby.

Answers one question: how does your mail look on their end?

mailreport reads the reports receivers send back about your domain.

Features

  • DMARC aggregate reports (RFC 7489 §7.2), gzipped, zipped, or bare
  • SMTP TLS reports (RFC 8460), gzipped, zipped, or bare
  • Bounded against hostile input, since the address these arrive at is published in DNS

A report is only as truthful as the receiver that filed it. This library reports what a document says, not whether it is true.

Contents

Getting Started

Add this line to your application's Gemfile:

gem "mailreport"

Requires Ruby >= 3.3.4

DMARC Aggregate Reports

A domain asks for these with the rua tag of its DMARC record, and they arrive as attachments to ordinary mail. Hand over the attachment as it came:

report = MailReport::Dmarc.parse(attachment_bytes)

report.organization   # => "google.com"
report.report_id      # => "14054835605949254452"
report.range          # => 2026-07-27 00:00:00 UTC..2026-07-27 23:59:59 UTC

report.policy.domain  # => "example.com"
report.policy.p       # => "reject"

Each record is one sending address over the window, with the count of messages it sent:

report.records.each do |record|
  record.source_ip    # => "203.0.113.9"
  record.count        # => 42
  record.disposition  # => "none" - what the receiver did
  record.dkim         # => "pass" - aligned, as DMARC judged it
  record.spf          # => "pass"
end

record.dkim and record.spf are the aligned verdicts DMARC reached. Whether the checks themselves passed is a different question, and it's in auth_results:

record.auth_results.dkim  # => [#<DkimAuthResult domain: "example.com", selector: "s1", result: "pass", ...>]
record.auth_results.spf   # => [#<SpfAuthResult domain: "example.com", scope: "mfrom", result: "pass">]

A message can carry a valid signature from a domain that doesn't align, and that pair is where it shows.

Where a receiver applied something milder than the published policy, it says why:

record.reasons  # => [#<Reason type: "forwarded", comment: "looks forwarded">]

Worth reading before concluding anything from a fail - forwarding breaks SPF and often DKIM, and a receiver that noticed will say so here.

SMTP TLS Reports

RFC 8460 reports arrive the same way. Parse the attachment bytes:

report = MailReport::TlsRpt.parse(attachment_bytes)

report.organization  # => "Company-X"
report.report_id     # => "5065427c-23d3-47ca-b6e0-946ea0e8c4be"
report.range         # => 2016-04-01 00:00:00 UTC..2016-04-01 23:59:59 UTC

report.policies.each do |policy|
  policy.type       # => "sts"
  policy.domain     # => "company-y.example"
  policy.successes  # => 5326
  policy.failures   # => 303
end

What Is Not Filled In

Elements the sender omitted come back nil, including ones the RFC gives defaults for:

report.policy.adkim  # => nil, where the sender said nothing

"The sender omitted adkim" and "the sender asked for relaxed alignment" are different facts, and only the first is in the document. Applying the RFC's defaults is left to the reader.

For the same reason, nothing here counts. Totals, alignment rates, and what counts as alarming are a consumer's to compute.

Unreadable Documents

Nothing raises. A document that can't be read is nil:

MailReport::Dmarc.parse(garbage)  # => nil

This is deliberately different from a report carrying no records - that one is a receiver saying it saw no mail, and comes back as a report with an empty records.

A rua address is published in DNS, so anyone can send anything to it, and the limits assume as much:

  • Entity declarations are refused. A report has none of its own, so the expansion bomb is refused with them. Escapes a real document needs (&amp;, &#233;) still read.
  • Decompression stops at 2 MB rather than running to completion.
MailReport::Archive.open(bytes, max_size: 1024 * 1024)
  • A document of more than 25,000 tags is refused, however few bytes it spends on them. Tags cost time and memory to read whatever their size, so the count is bounded to 500 records.
  • External references are never resolved - no document reads a file, a URL, or a DNS name.
  • A zip entry is bounded as it inflates, not on the size it claims.

Where an archive holds several entries, DMARC prefers a .xml name and TLS a .json name; otherwise the first non-empty file is read. Directories and empty placeholders are skipped.

Testing

bundle install
rake

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.