PROJECTIntermediate

DMARC Aggregate Report Monitoring with ParseDMARC, Elasticsearch, and Grafana

Deploy an open-source pipeline that pulls DMARC aggregate reports from a mailbox, indexes them in Elasticsearch, and visualizes spoofing attempts in Grafana.

Dylan H.

Projects

September 30, 2026
7 min read
3-5 hours
DMARC Aggregate Report Monitoring with ParseDMARC, Elasticsearch, and Grafana

Tools & Technologies

ParseDMARCElasticsearchGrafanaDocker ComposeIMAP mailbox

Overview

DMARC (Domain-based Message Authentication, Reporting & Conformance) ties SPF and DKIM together and tells receiving mail servers what to do when a message fails both — quarantine it, reject it, or let it through. Publishing a DMARC record with rua=mailto:... also gets you something most domains never look at: daily aggregate XML reports from every major mailbox provider (Google, Microsoft, Yahoo) showing every source that sent mail claiming to be your domain, and whether it passed authentication.

Those reports are the only reliable way to catch a spoofed sender, a misconfigured SPF record that's silently failing legitimate mail, or a forgotten marketing tool sending as your domain without DKIM signing — but raw DMARC XML is unreadable at scale. ParseDMARC is an open-source Python tool that watches a mailbox, parses incoming aggregate and forensic reports, and ships structured data to Elasticsearch (or Splunk, Kafka, OpenSearch). This build wires ParseDMARC to a dedicated reporting mailbox, indexes results in Elasticsearch, and layers Grafana on top for dashboards — reusing the same Elasticsearch-as-a-datasource pattern as the site's Prometheus + Grafana monitoring stack instead of standing up Kibana as a second visualization tool.

By the end you'll have live visibility into every system sending mail as your domain, a Grafana dashboard breaking down pass/fail rates by source IP and organization, and the DNS records needed to actually start receiving reports.

Architecture

 Sending mail servers (Google, MS365, etc.)
        │  evaluate SPF/DKIM/DMARC against your DNS records
        ▼
 Aggregate report emails (compressed XML, ~daily per reporter)
        │  delivered via SMTP
        ▼
 ┌───────────────────────┐
 │ IMAP mailbox           │  dmarc-reports@yourdomain.tld
 │ (dedicated, app-pass)  │
 └───────────┬────────────┘
             │ polled every N minutes
             ▼
 ┌───────────────────────┐
 │ parsedmarc (Docker)    │  parses XML, does GeoIP + reverse DNS
 │ parsedmarc.ini config  │  enrichment on source IPs
 └───────────┬────────────┘
             │ bulk index
             ▼
 ┌───────────────────────┐
 │ Elasticsearch          │  dmarc_aggregate / dmarc_forensic indices
 │ single-node, Docker    │
 └───────────┬────────────┘
             │ Elasticsearch datasource
             ▼
 ┌───────────────────────┐
 │ Grafana                │  pass/fail trend, top sources, policy
 │ dashboards + alerting  │  disposition breakdown
 └───────────────────────┘

Nothing here touches your production mail flow — ParseDMARC only reads reports about your domain's authentication history, it doesn't sit in the SMTP path.

Step-by-Step Build

1. Publish (or tighten) SPF, DKIM, and DMARC records

Skip this if SPF/DKIM are already live. Start DMARC in monitor-only mode (p=none) — never jump straight to p=reject on a domain you haven't observed yet, or you'll bounce your own legitimate mail the moment a forgotten sender fails.

; SPF - list every service that sends as your domain
yourdomain.tld.        TXT   "v=spf1 include:_spf.google.com include:sendgrid.net ~all"

; DMARC - monitor mode, aggregate reports only
_dmarc.yourdomain.tld. TXT   "v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.tld; ruf=mailto:dmarc-reports@yourdomain.tld; fo=1; pct=100"

rua is where aggregate (summary) reports go; ruf is forensic (per-message failure) reports — many providers don't send ruf due to privacy policies, but enable it anyway. fo=1 requests a forensic report if either SPF or DKIM fails, not just when both fail.

2. Create the dedicated reporting mailbox

Use a real mailbox (Google Workspace, M365, or a self-hosted inbox) — not a forward, since ParseDMARC needs IMAP access to mark messages read/move them after parsing. Generate an app-specific password rather than using the account's primary credential.

3. Lay out the Docker Compose stack

# docker-compose.yml
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.15.3
    container_name: dmarc-elasticsearch
    restart: unless-stopped
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms1g -Xmx1g"
    volumes:
      - ./es-data:/usr/share/elasticsearch/data
    ports:
      - "9200:9200"
 
  parsedmarc:
    image: ghcr.io/domainaware/parsedmarc:latest
    container_name: dmarc-parser
    restart: unless-stopped
    depends_on:
      - elasticsearch
    volumes:
      - ./parsedmarc.ini:/etc/parsedmarc.ini:ro
    command: ["parsedmarc", "-c", "/etc/parsedmarc.ini"]
 
  grafana:
    image: grafana/grafana-oss:11.3.0
    container_name: dmarc-grafana
    restart: unless-stopped
    depends_on:
      - elasticsearch
    environment:
      - GF_INSTALL_PLUGINS=
    volumes:
      - ./grafana-data:/var/lib/grafana
    ports:
      - "3000:3000"

Elasticsearch's memory-mapped storage needs a raised vm.max_map_count; set it on the Docker host before first boot:

sudo sysctl -w vm.max_map_count=262144

4. Configure ParseDMARC

# parsedmarc.ini
[general]
save_aggregate = True
save_forensic = True
 
[imap]
host = imap.yourdomain.tld
user = dmarc-reports@yourdomain.tld
password = <app-specific-password>
watch = True
delete = False
archive_folder = Archive
 
[elasticsearch]
hosts = elasticsearch:9200
ssl = False
index_suffix = _dmarc

watch = True keeps ParseDMARC running persistently against the mailbox via IMAP IDLE instead of one-shot polling; delete = False leaves originals in place and files them into archive_folder so you can re-parse later if the index needs rebuilding.

mkdir -p es-data grafana-data
sudo chown -R 1000:1000 es-data   # elasticsearch container user
docker compose up -d
docker compose logs -f parsedmarc

Watch the parsedmarc logs for the first successful IMAP connection and report parse — new reports typically don't arrive until the next day's batch from each provider, so don't expect data within minutes of a fresh DMARC record.

5. Point Grafana at Elasticsearch

In Grafana (http://<host>:3000, default admin/admin on first login): Connections → Data sources → Add data source → Elasticsearch, URL http://elasticsearch:9200, index name pattern dmarc_aggregate*, time field date_range_begin. Save & test.

Build panels against the index:

  • Pass/fail trend — time series, split by policy_evaluated.disposition (none / quarantine / reject)
  • Top sending sources — table, terms aggregation on source.ip_address, sorted by count descending
  • Authentication results by organization — bar chart, terms on identifiers.header_from vs. auth_results.dkim.result / auth_results.spf.result
  • Failing sources needing SPF/DKIM fixes — table filtered to policy_evaluated.disposition != none alongside spf.result: fail OR dkim.result: fail

That last panel is the one to actually act on: every row is either a spoofing attempt or a legitimate sender you forgot to add to your SPF record.

Testing

  • docker compose logs parsedmarc — confirms IMAP login succeeds and reports are being parsed without XML errors.
  • curl http://localhost:9200/dmarc_aggregate*/_count — index document count should climb daily as new reports land.
  • Use dmarcian's DMARC record checker or dig TXT _dmarc.yourdomain.tld to confirm the published record resolves and syntax is valid before waiting on report delivery.
  • Send a test message through a source that's not in your SPF record (e.g., a personal Gmail account, From: spoofed to your domain via a test tool) — within a day or two it should show up in the Grafana fail panel, proving the whole pipeline from DNS record to dashboard actually works end to end.
  • Cross-check one raw report manually: docker compose exec parsedmarc parsedmarc <path-to-sample.xml> --output /tmp/out and diff the parsed JSON against what landed in Elasticsearch.

Deployment Notes

  • Keep DMARC at p=none for at least 2-4 weeks while you review the dashboard for legitimate senders that are failing — only then move to p=quarantine, and only to p=reject after that shows clean.
  • Elasticsearch single-node with xpack.security.enabled=false is fine for a homelab reading only your own DMARC metadata; don't expose port 9200 outside the Docker network.
  • ParseDMARC's delete = False plus archive_folder means the mailbox grows indefinitely — set a mail-provider retention rule or periodically prune the archive folder once reports are confirmed indexed.
  • If a provider stops sending expected reports, check their reporting size caps — Elasticsearch indexing failures on giant reports show up in the parsedmarc logs before they show up as "missing data" in Grafana.
  • Set an Elasticsearch index lifecycle policy (ILM) once volume grows — DMARC aggregate indices are small individually but accumulate one index per day indefinitely without one.

Extensions

  • Add a Grafana alert rule that fires to the same Discord/Slack channel as other infra alerts when a new source IP starts failing DMARC at volume — that's the fastest signal of an active spoofing campaign.
  • Feed ruf forensic reports (when providers send them) into a separate index and dashboard — they include full failed-message headers, useful for tracing exactly what a spoofed message looked like.
  • Layer in MTA-STS and TLS-RPT reporting (ParseDMARC supports both) to catch downgrade attacks and TLS delivery failures alongside authentication failures.
  • Once p=reject is stable, extend the same Grafana instance to track BIMI record adoption — BIMI requires DMARC enforcement as a prerequisite, so this pipeline doubles as proof you're ready for it.

Sources: ParseDMARC documentation, ParseDMARC GitHub, dmarcian: DMARC record reference, Self-hosted DMARC parsing 2026: parsedmarc + Elasticsearch guide, Elastic: Elasticsearch Docker install docs