CLI tool
domainaware/parsedmarc avatar
domainaware/parsedmarc

parsedmarc: parsing DMARC aggregate, failure and TLS-RPT reports into your own search stack

A Python package and CLI for parsing aggregate and forensic DMARC reports

1,308 stars271 forksPythonApache-2.0

At a glance

What is it?
parsedmarc turns XML email authentication reports into JSON, CSV or search-engine documents, so a security team can keep DMARC telemetry in Elasticsearch, OpenSearch, Splunk or PostgreSQL instead of a commercial reporting service. The package splits its heavy SDK dependencies behind extras, which is what makes a small install possible.
Who is it for?
Adopt parsedmarc if you want DMARC report data in a stack you control, since the parser covers the legacy and the RFC 9990 aggregate schemas, failure reports and TLS-RPT, and can write straight to Elasticsearch, OpenSearch, Splunk, PostgreSQL, Kafka, S3, Sentinel, Graylog, syslog or a webhook. Do not adopt it expecting a full DMARC product, since the dashboards are a separate artefact in the dashboards directory and the commercial services it names do more than parse.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository received new commits within the last day.
What is it written in?
Mainly Python, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

What parsedmarc parses, and for whom it matters

parsedmarc is a Python module and CLI utility for parsing DMARC reports, and the README frames it as a self-hosted open source alternative to commercial DMARC report processing services such as Agari Brand Protection, Dmarcian, OnDMARC, ProofPoint Email Fraud Defense and Valimail, when combined with Elasticsearch and Kibana, or with OpenSearch and Grafana.

The protocol it handles is Domain-based Message Authentication, Reporting and Conformance, an email authentication protocol, and the point of the reports is that a receiving mail server tells the sending domain what happened to its mail.

The parsing coverage is broad, and it is the breadth that distinguishes this from a simple XML to dict converter. Aggregate reports, called rua, are parsed in the legacy draft and 1.0 schemas under RFC 7489, and also in the new RFC 9990 schema for the final DMARC standard, RFC 9989. Failure reports, called ruf and formerly called forensic reports, are handled under RFC 6591 and RFC 9991. Reports from SMTP TLS Reporting, TLS-RPT under RFC 8460, are parsed too, which is the TLS side rather than the SPF or DKIM side.

Input can come from files, or be pulled from an inbox over IMAP, Microsoft Graph or the Gmail API, and compressed reports are handled transparently whether they are gzip or zip. The output side is consistent data structures, then simple JSON and/or CSV, and optionally an email of the results.

The project is maintained by one developer, Sean Whalen, and the README asks for sponsorship for that reason.

The extras split, and what the base install actually covers

The dependency strategy is the most interesting engineering decision in the package, and the pyproject.toml explains it in a comment.

The base install is described as the parsing core plus a working core CLI: file, IMAP, Maildir and mbox input, with CSV, JSON, Splunk HEC, webhook and syslog output. Every other output and mailbox integration lives in an extra, so a library or small-mail-host install does not have to carry the Elasticsearch, OpenSearch, Kafka, AWS, Azure, Gmail and Microsoft Graph SDKs. The comment references issue #883 as the origin of that decision.

toml
requires-python = ">=3.10,<3.15"
license = "Apache-2.0"
classifiers = [
    "Development Status :: 5 - Production/Stable",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Programming Language :: Python :: 3.14",
]

The base dependency list is deliberately small, with dnspython carrying the doh extra for DNS over HTTPS at a floor of 2.7.0, verified against the dns.nameserver and dns.query.https APIs that utils.py builds on, and expiringdict for the cache.

That floor version is stated with a reason, which is the sort of detail that saves an hour. The comment says 2.7.0 is the floor verified against those specific APIs, so a newer dnspython is fine and an older one is not.

The extras are additive on top of that base, and each one corresponds to a destination system or a mailbox provider. The consequence for a user is that a small deployment installs a small set of packages, while a full one installs the SDKs for every integration you enabled.

Python version support, and the reason for each exclusion

The README publishes a table of Python versions with a reason column, which is unusual and useful.

The supported set is 3.10, 3.11, 3.12, 3.13 and 3.14. The rule the README states is that the supported versions are either actively maintained or are the default versions for RHEL or Debian.

The exclusions have their own reasons rather than a blanket policy. Python below 3.6, and 3.7 and 3.8, are listed as End of Life. Python 3.6 is excluded because it is used in RHEL 8 but is not supported by the project dependencies, and 3.9 is excluded for the same class of reason, being used in Debian 11 and RHEL 9 but not supported by the project dependencies.

So a distribution's default Python version is not automatically supported, which is the trap for anyone installing on an older enterprise base image. The reasons for support are also dated, with 3.11 marked as supported until June 2028 on Debian 12, 3.12 until May 2035 on RHEL 10, 3.13 until June 2030 on Debian 13, and 3.14 marked as supported but requiring imapclient>=3.1.0.

The last row is a concrete dependency condition attached to a Python version, and it is the kind of detail worth checking before you pick 3.14 for a fresh install.

Running the stack locally with the bundled compose file

The repository ships a docker-compose.yml, and it defines both an Elasticsearch and an OpenSearch service, so you can develop against either backend.

The Elasticsearch service uses the image docker.elastic.co/elasticsearch/elasticsearch:8.19.7, binds to the loopback interface with network.host set to 127.0.0.1 while http.host is 0.0.0.0, runs as a single node with discovery.type single-node, and disables xpack.security entirely with a self-generated basic licence.

yaml
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.19.7
    environment:
      - network.host=127.0.0.1
      - http.host=0.0.0.0
      - node.name=elasticsearch
      - discovery.type=single-node
      - cluster.name=parsedmarc-cluster
      - xpack.security.enabled=false
      - xpack.license.self_generated.type=basic
      - ES_JAVA_OPTS=${ES_JAVA_OPTS:--Xms2g -Xmx2g}
    ports:
      - "127.0.0.1:9200:9200"

Two settings in that block carry comments explaining the reasoning. The explicit heap exists because, without one, Elasticsearch sizes the heap to 50 percent of host RAM and mlocks it through bootstrap.memory_lock, which OOM-kills the container on large hosts, and the file says to override it via ES_JAVA_OPTS in a .env file for smaller machines. The healthcheck greps the cluster health output for a green or yellow status, on a ten second interval with 24 retries.

The OpenSearch service runs opensearchproject/opensearch:3, keeps the same single-node discovery settings and the same cluster name, and takes an admin password from the environment through OPENSEARCH_INITIAL_ADMIN_PASSWORD. Its ports mapping is 127.0.0.1:9201 to 9200, and its healthcheck uses curl with the admin credentials over https.

Both services set a memlock ulimit of unlimited, matching the memory_lock setting.

The Dockerfile for the tool itself builds on python:3.13-slim by default, with the base image and the non-root user passed in as build arguments, and the compose file is paired with a dashboard-dev-bootstrap.sh script and a docker-compose.dashboard-dev.yml variant for working on the bundled dashboards.

Where parsedmarc stops and something else starts

The scope is parsing plus delivery, and it is worth being clear about the boundary.

parsedmarc does not send DMARC policy. It does not produce the aggregate or failure reports in the first place; your receiving mail servers and your DNS records do that. It reads reports that have already been delivered to an inbox or written to disk, and it turns them into documents you can query. A complete deployment therefore has three moving parts: a DNS policy, a report destination mailbox, and this parser in front of it.

The dashboards are a related but separate artefact. There is a dashboards/ directory in the repository, a dashboard-dev-bootstrap.sh script and a dashboard-dev-screenshots.py, and the README's screenshot shows DMARC summary charts in Kibana. That tells you the project ships visualisation work, but the charts are an input to your stack rather than the product itself, and the commercial services it names for comparison do considerably more than parse XML.

There is also a maintenance reality in the README. The project is maintained by one developer, and the sponsorship request is explicit. The recent releases show the pace: 11.0.3 on 2026-09-24, 11.0.2 on 2026-09-12 and 11.0.1 on 2026-09-03, and the last push was on 2026-10-13, with the repository not archived. Version 11 with patch releases roughly every week or two is a project in active upkeep, but it is one person's capacity.

The licence is Apache-2.0, which permits commercial use and modification with the notice retained, and that is the licence recorded in the pyproject.toml. The samples/ directory ships example inputs, with separate directories for aggregate reports, an aggregate_invalid set, an empty.xml, an extract_report case, failure reports and smtp_tls, which is useful as a test corpus when you extend the parser.

Editorial conclusion

Adopt parsedmarc if you want DMARC report data in a stack you control, since the parser covers the legacy and the RFC 9990 aggregate schemas, failure reports and TLS-RPT, and can write straight to Elasticsearch, OpenSearch, Splunk, PostgreSQL, Kafka, S3, Sentinel, Graylog, syslog or a webhook. Do not adopt it expecting a full DMARC product, since the dashboards are a separate artefact in the dashboards directory and the commercial services it names do more than parse. Verify first which Python version and which extras your environment needs, because the base install is deliberately limited to file, IMAP, Maildir and mbox input with CSV, JSON, Splunk HEC, webhook and syslog output.

Frequently asked questions

What report formats does parsedmarc parse?

It parses aggregate or rua reports in the legacy draft and 1.0 schemas under RFC 7489 and in the RFC 9990 schema for the final standard, failure or ruf reports under RFC 6591 and RFC 9991, and SMTP TLS Reporting or TLS-RPT reports under RFC 8460. Compressed gzip and zip archives are handled transparently.

Which Python versions does parsedmarc support?

Python 3.10 through 3.14 are supported. The README excludes 3.9 and 3.6 because they are the defaults for Debian 11, RHEL 9 and RHEL 8 but are not supported by the project dependencies, and 3.7 and 3.8 as End of Life. Python 3.14 additionally requires imapclient 3.1.0 or newer.

Where can parsedmarc send the parsed reports?

To Elasticsearch, OpenSearch, Splunk or PostgreSQL for use with premade dashboards, and to Apache Kafka, Amazon S3, Azure Log Analytics, a Graylog GELF endpoint, a syslog server or an HTTP webhook. Results can also be emailed, and the base CLI writes JSON or CSV.

How do I run parsedmarc against a local Elasticsearch for development?

The repository ships a docker-compose.yml with an Elasticsearch service on 127.0.0.1:9200 and an OpenSearch service on 127.0.0.1:9201, both single-node with security disabled, alongside a dashboard-dev-bootstrap.sh script and a dashboard-dev compose variant. The Elasticsearch heap is set explicitly through ES_JAVA_OPTS because the default sizing OOM-kills the container on large hosts.

Official sources

  1. domainaware/parsedmarc on GitHub
  2. License: Apache-2.0
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/domainaware-parsedmarc.svg)](https://hysenlabs.com/projects/domainaware-parsedmarc)

Was this useful?