diff --git a/backend/app/tests/fixtures/dmarc_aggregate/rfc7489-google.xml b/backend/app/tests/fixtures/dmarc_aggregate/rfc7489-google.xml
new file mode 100644
index 0000000..3e834a8
--- /dev/null
+++ b/backend/app/tests/fixtures/dmarc_aggregate/rfc7489-google.xml
@@ -0,0 +1,45 @@
+
+
+
+ google.com
+ noreply-dmarc-support@google.com
+ 123456789
+
+ 1597449600
+ 1597535999
+
+
+
+ example.com
+ r
+ r
+ none
+ none
+ 100
+
+
+
+ 203.0.113.1
+ 2
+
+ none
+ pass
+ fail
+
+
+
+ example.com
+
+
+
+ example.com
+ pass
+ default
+
+
+ example.com
+ fail
+
+
+
+
diff --git a/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-multi-auth-overrides.xml b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-multi-auth-overrides.xml
new file mode 100644
index 0000000..fa7cad3
--- /dev/null
+++ b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-multi-auth-overrides.xml
@@ -0,0 +1,100 @@
+
+
+ 1.0
+
+ Mailbox Provider
+ dmarc-reports@mailbox.example
+ mailto:dmarc-help@mailbox.example
+ fixture-rfc9990-multi-auth
+
+ 1779580800
+ 1779667199
+
+ MailboxRUA 2026.05
+
+
+ example.net
+ psd
+ reject
+ quarantine
+ reject
+ 1:d:s
+ r
+ s
+ 75
+ n
+
+
+
+ Mailbox Provider
+ us-east
+
+
+
+
+ 192.0.2.44
+ 10
+
+ reject
+ fail
+ fail
+
+ forwarded
+ forwarder modified body
+
+
+
+
+ billing.example.net
+ bounce.example.net
+ customer.example.org
+
+
+
+ example.net
+ mail
+ fail
+ signature expired
+
+
+ vendor.example
+ relay
+ pass
+
+
+ bounce.example.net
+ mfrom
+ fail
+ ip not authorized
+
+
+ transactional
+
+
+
+ 198.51.100.77
+ 5
+
+ none
+ pass
+ fail
+
+
+
+ news.example.net
+ mailer.example.net
+
+
+
+ example.net
+ news
+ pass
+
+
+ mailer.example.net
+ mfrom
+ fail
+
+
+
+
diff --git a/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-namespaced-legacy-fields.xml b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-namespaced-legacy-fields.xml
new file mode 100644
index 0000000..4bd01fa
--- /dev/null
+++ b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-namespaced-legacy-fields.xml
@@ -0,0 +1,45 @@
+
+
+
+ web.de
+ dmarc@web.de
+ 987654321
+
+ 1597449600
+ 1597535999
+
+
+
+ example.com
+ r
+ r
+ reject
+ reject
+ 100
+
+
+
+ 198.51.100.5
+ 3
+
+ reject
+ pass
+ pass
+
+
+
+ example.com
+
+
+
+ example.com
+ pass
+ s1
+
+
+ example.com
+ pass
+
+
+
+
diff --git a/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-treewalk-extension.xml b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-treewalk-extension.xml
new file mode 100644
index 0000000..218e551
--- /dev/null
+++ b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-treewalk-extension.xml
@@ -0,0 +1,65 @@
+
+
+ 1.0
+
+ Example Receiver
+ dmarc@example.test
+ https://example.test/dmarc
+ fixture-rfc9990-treewalk
+
+ 1779494400
+ 1779580799
+
+ Multiple DMARC records were ignored before treewalk.
+ ExampleRUA 2.0
+
+
+ example.org
+ treewalk
+ quarantine
+ reject
+ none
+ 1
+ s
+ r
+ y
+
+
+ mx1.example.test
+
+
+
+ 2001:db8::1
+ 5
+
+ quarantine
+ fail
+ pass
+
+ local_policy
+ trusted relay
+
+
+
+
+ news.example.org
+ bounce.example.org
+ customer.example.net
+
+
+
+ example.net
+ selector1
+ fail
+ body hash did not verify
+
+
+ bounce.example.org
+ mfrom
+ pass
+ sender authorized
+
+
+ mail-platform
+
+
diff --git a/backend/app/tests/test_data.py b/backend/app/tests/test_data.py
index b418553..d1d38df 100644
--- a/backend/app/tests/test_data.py
+++ b/backend/app/tests/test_data.py
@@ -1,97 +1,69 @@
"""Shared test data for DMARC report tests."""
-SAMPLE_XML = """\
-
-
-
- google.com
- noreply-dmarc-support@google.com
- 123456789
-
- 1597449600
- 1597535999
-
-
-
- example.com
- r
- r
- none
- none
- 100
-
-
-
- 203.0.113.1
- 2
-
- none
- pass
- fail
-
-
-
- example.com
-
-
-
- example.com
- pass
- default
-
-
- example.com
- fail
-
-
-
-
-"""
+from pathlib import Path
-SAMPLE_XML_WITH_NAMESPACE = """\
-
-
-
- web.de
- dmarc@web.de
- 987654321
-
- 1597449600
- 1597535999
-
-
-
- example.com
- r
- r
- reject
- reject
- 100
-
-
-
- 198.51.100.5
- 3
-
- reject
- pass
- pass
-
-
-
- example.com
-
-
-
- example.com
- pass
- s1
-
-
- example.com
- pass
-
-
-
-
-"""
+
+DMARC_FIXTURE_DIR = Path(__file__).with_name("fixtures") / "dmarc_aggregate"
+
+
+def load_dmarc_fixture(filename: str) -> str:
+ """Load a curated aggregate-report fixture as text."""
+ return (DMARC_FIXTURE_DIR / filename).read_text(encoding="utf-8")
+
+
+DMARC_COMPATIBILITY_FIXTURES = [
+ {
+ "id": "rfc7489-google",
+ "filename": "rfc7489-google.xml",
+ "domain": "example.com",
+ "report_id": "123456789",
+ "variant": "rfc7489-compatible",
+ "total_count": 2,
+ },
+ {
+ "id": "rfc9990-namespaced-legacy-fields",
+ "filename": "rfc9990-namespaced-legacy-fields.xml",
+ "domain": "example.com",
+ "report_id": "987654321",
+ "variant": "rfc9990",
+ "total_count": 3,
+ },
+ {
+ "id": "rfc9990-treewalk-extension",
+ "filename": "rfc9990-treewalk-extension.xml",
+ "domain": "example.org",
+ "report_id": "fixture-rfc9990-treewalk",
+ "variant": "rfc9990",
+ "schema_version": "1.0",
+ "total_count": 5,
+ "policy": {
+ "p": "quarantine",
+ "sp": "reject",
+ "np": "none",
+ "fo": "1",
+ "testing": "y",
+ "discovery_method": "treewalk",
+ },
+ },
+ {
+ "id": "rfc9990-multi-auth-overrides",
+ "filename": "rfc9990-multi-auth-overrides.xml",
+ "domain": "example.net",
+ "report_id": "fixture-rfc9990-multi-auth",
+ "variant": "rfc9990",
+ "schema_version": "1.0",
+ "total_count": 15,
+ "policy": {
+ "p": "reject",
+ "sp": "quarantine",
+ "np": "reject",
+ "fo": "1:d:s",
+ "testing": "n",
+ "discovery_method": "psd",
+ },
+ },
+]
+
+SAMPLE_XML = load_dmarc_fixture("rfc7489-google.xml")
+
+SAMPLE_XML_WITH_NAMESPACE = load_dmarc_fixture("rfc9990-namespaced-legacy-fields.xml")
diff --git a/backend/app/tests/test_dmarc_compatibility_fixtures.py b/backend/app/tests/test_dmarc_compatibility_fixtures.py
new file mode 100644
index 0000000..a28332c
--- /dev/null
+++ b/backend/app/tests/test_dmarc_compatibility_fixtures.py
@@ -0,0 +1,193 @@
+import base64
+import email
+import gzip
+import io
+import zipfile
+from email import encoders as email_encoders
+from email.mime.application import MIMEApplication
+from email.mime.base import MIMEBase
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from unittest.mock import MagicMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.models.report import DMARCReport
+from app.services.dmarc_parser import DMARCParser
+from app.services.gmail_client import GmailClient
+from app.services.imap_client import IMAPClient
+from app.tests.test_data import DMARC_COMPATIBILITY_FIXTURES, load_dmarc_fixture
+
+
+def _fixture_id(fixture: dict) -> str:
+ return fixture["id"]
+
+
+def _fixture_bytes(fixture: dict) -> bytes:
+ return load_dmarc_fixture(fixture["filename"]).encode("utf-8")
+
+
+def _zip_xml(xml_content: bytes, filename: str = "report.xml") -> bytes:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as archive:
+ archive.writestr(filename, xml_content)
+ return buf.getvalue()
+
+
+def _make_mime_attachment(filename: str, content: bytes, content_type: str) -> bytes:
+ msg = MIMEMultipart()
+ msg["Subject"] = "DMARC aggregate report"
+ msg["From"] = "reports@example.test"
+ msg["To"] = "dmarc@example.test"
+ msg.attach(MIMEText("DMARC report attached.", "plain"))
+
+ part = MIMEApplication(content, Name=filename)
+ part["Content-Disposition"] = f'attachment; filename="{filename}"'
+ part.set_type(content_type)
+ msg.attach(part)
+ return msg.as_bytes()
+
+
+def _make_gmail_raw_attachment(filename: str, content: bytes) -> bytes:
+ msg = MIMEMultipart()
+ msg["Subject"] = "DMARC aggregate report"
+ msg["From"] = "reports@example.test"
+ msg["To"] = "dmarc@example.test"
+ msg.attach(MIMEText("DMARC report attached.", "plain"))
+
+ part = MIMEBase("application", "zip")
+ part.set_payload(content)
+ email_encoders.encode_base64(part)
+ part.add_header("Content-Disposition", "attachment", filename=filename)
+ msg.attach(part)
+ return msg.as_bytes()
+
+
+def _make_imap_client(db_session) -> IMAPClient:
+ with patch("app.services.imap_client.get_settings") as mock_settings:
+ mock_settings.return_value = MagicMock(
+ IMAP_SERVER="imap.example.test",
+ IMAP_PORT=993,
+ IMAP_USERNAME="dmarc@example.test",
+ IMAP_PASSWORD="password",
+ )
+ return IMAPClient(db=db_session)
+
+
+def _make_gmail_client(db_session) -> GmailClient:
+ with patch("app.services.gmail_client.Credentials") as mock_credentials_class:
+ credentials = MagicMock()
+ credentials.token = "access-token"
+ credentials.refresh_token = "refresh-token"
+ credentials.expired = False
+ mock_credentials_class.return_value = credentials
+ return GmailClient(
+ client_id="client-id",
+ client_secret="client-secret",
+ access_token="access-token",
+ refresh_token="refresh-token",
+ db=db_session,
+ )
+
+
+def _assert_expected_report(report: dict, fixture: dict) -> None:
+ assert report["domain"] == fixture["domain"]
+ assert report["report_id"] == fixture["report_id"]
+ assert report["variant"] == fixture["variant"]
+ assert report["summary"]["total_count"] == fixture["total_count"]
+
+ for key, value in fixture.get("policy", {}).items():
+ assert report["policy"][key] == value
+
+
+def _assert_persisted_report(db_session, fixture: dict) -> DMARCReport:
+ db_session.flush()
+ report = db_session.query(DMARCReport).filter_by(report_id=fixture["report_id"]).one()
+ assert report.domain.name == fixture["domain"]
+ assert report.report_variant == fixture["variant"]
+ assert sum(record.count for record in report.records) == fixture["total_count"]
+ return report
+
+
+@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id)
+def test_aggregate_compatibility_fixtures_parse_xml_zip_and_gzip(fixture):
+ xml_bytes = _fixture_bytes(fixture)
+
+ xml_report = DMARCParser.parse_file(xml_bytes, fixture["filename"])
+ zip_report = DMARCParser.parse_file(_zip_xml(xml_bytes), fixture["filename"] + ".zip")
+ gzip_report = DMARCParser.parse_file(gzip.compress(xml_bytes), fixture["filename"] + ".gz")
+
+ for report in (xml_report, zip_report, gzip_report):
+ _assert_expected_report(report, fixture)
+
+
+@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id)
+def test_aggregate_compatibility_fixtures_import_via_upload(
+ client: TestClient, db_session, fixture
+):
+ zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"])
+
+ response = client.post(
+ "/api/v1/reports/upload",
+ files={"file": (fixture["filename"] + ".zip", zip_bytes, "application/zip")},
+ )
+
+ assert response.status_code == 200
+ _assert_persisted_report(db_session, fixture)
+
+ reports = client.get(f"/api/v1/domains/{fixture['domain']}/reports")
+ assert reports.status_code == 200
+ assert reports.json()["reports"][0]["id"] == fixture["report_id"]
+
+ export = client.get(f"/api/v1/domains/{fixture['domain']}/reports/export")
+ assert export.status_code == 200
+ assert fixture["report_id"] in export.text
+ assert "report_variant" in export.text
+
+
+@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id)
+def test_aggregate_compatibility_fixtures_import_via_imap(db_session, fixture):
+ client = _make_imap_client(db_session)
+ zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"])
+ raw_message = _make_mime_attachment(fixture["filename"] + ".zip", zip_bytes, "application/zip")
+ msg = email.message_from_bytes(raw_message)
+ stats = {"processed": 0, "reports_found": 0, "errors": []}
+
+ count = client._process_attachments(msg, stats, message_id="imap-" + fixture["id"])
+
+ assert count == 1
+ assert stats["details"][0]["status"] == "imported"
+ assert stats["details"][0]["report_id"] == fixture["report_id"]
+ _assert_persisted_report(db_session, fixture)
+
+
+@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id)
+def test_aggregate_compatibility_fixtures_import_via_gmail(db_session, fixture):
+ client = _make_gmail_client(db_session)
+ zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"])
+ raw_message = _make_gmail_raw_attachment(fixture["filename"] + ".zip", zip_bytes)
+ msg = email.message_from_bytes(raw_message)
+ stats = {"reports_found": 0, "errors": []}
+
+ count = client._process_attachments(msg, stats, message_id="gmail-" + fixture["id"])
+
+ assert count == 1
+ assert stats["details"][0]["status"] == "imported"
+ assert stats["details"][0]["report_id"] == fixture["report_id"]
+ _assert_persisted_report(db_session, fixture)
+
+
+def test_gmail_fixture_email_shape_matches_api_raw_encoding():
+ """The fixture MIME shape can be decoded from Gmail's raw message payload form."""
+ fixture = DMARC_COMPATIBILITY_FIXTURES[2]
+ zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"])
+ encoded = base64.urlsafe_b64encode(
+ _make_gmail_raw_attachment(fixture["filename"] + ".zip", zip_bytes)
+ )
+
+ decoded = base64.urlsafe_b64decode(encoded)
+ msg = email.message_from_bytes(decoded)
+
+ assert msg["Subject"] == "DMARC aggregate report"
+ assert any(part.get_filename() == fixture["filename"] + ".zip" for part in msg.walk())
diff --git a/backend/app/tests/test_dmarc_parser.py b/backend/app/tests/test_dmarc_parser.py
index 440e523..c34785a 100644
--- a/backend/app/tests/test_dmarc_parser.py
+++ b/backend/app/tests/test_dmarc_parser.py
@@ -4,7 +4,7 @@ import zipfile
import pytest
from app.services.dmarc_parser import DMARCParser
-from app.tests.test_data import SAMPLE_XML, SAMPLE_XML_WITH_NAMESPACE
+from app.tests.test_data import SAMPLE_XML, SAMPLE_XML_WITH_NAMESPACE, load_dmarc_fixture
class TestDMARCParser:
@@ -132,80 +132,14 @@ class TestDMARCParser:
def test_parse_rfc9990_style_report_variant(self):
"""RFC 9990-era namespaces and optional fields should parse without breaking legacy shape."""
- xml = b"""
-
- 1.0
-
- Example Receiver
- dmarc@example.test
- https://example.test/dmarc
- 2026-05-23-example.org
-
- 1779494400
- 1779580799
-
- Multiple DMARC records were ignored before treewalk.
- ExampleRUA 2.0
-
-
- example.org
- treewalk
- quarantine
- reject
- none
- 1
- s
- r
- y
-
-
- mx1.example.test
-
-
-
- 2001:db8::1
- 5
-
- quarantine
- fail
- pass
-
- local_policy
- trusted relay
-
-
-
-
- news.example.org
- bounce.example.org
- customer.example.net
-
-
-
- example.net
- selector1
- fail
- body hash did not verify
-
-
- bounce.example.org
- mfrom
- pass
- sender authorized
-
-
- mail-platform
-
-
- """
+ xml = load_dmarc_fixture("rfc9990-treewalk-extension.xml").encode("utf-8")
result = DMARCParser.parse_file(xml, "report.xml")
assert result["variant"] == "rfc9990"
assert result["schema_version"] == "1.0"
assert result["xml_namespace"] == "urn:ietf:params:xml:ns:dmarc-2.0"
- assert result["report_id"] == "2026-05-23-example.org"
+ assert result["report_id"] == "fixture-rfc9990-treewalk"
assert result["generator"] == "ExampleRUA 2.0"
assert result["errors"] == ["Multiple DMARC records were ignored before treewalk."]
assert result["extensions"] == {"receiver": "mx1.example.test"}
diff --git a/backend/app/tests/test_reports_api.py b/backend/app/tests/test_reports_api.py
index 1e9889e..b770a60 100644
--- a/backend/app/tests/test_reports_api.py
+++ b/backend/app/tests/test_reports_api.py
@@ -7,75 +7,10 @@ from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
from app.services.report_persistence import persisted_report_to_dict
from app.services.report_store import ReportStore
-from app.tests.test_data import SAMPLE_XML
+from app.tests.test_data import SAMPLE_XML, load_dmarc_fixture
-SAMPLE_RFC9990_XML = """
-
- 1.0
-
- Example Receiver
- dmarc@example.test
- https://example.test/dmarc
- 2026-05-23-example.org
-
- 1779494400
- 1779580799
-
- Multiple records ignored.
- ExampleRUA 2.0
-
-
- example.org
- treewalk
- quarantine
- reject
- none
- 1
- s
- r
- y
-
-
- mx1.example.test
-
-
-
- 2001:db8::1
- 5
-
- quarantine
- fail
- pass
-
- local_policy
- trusted relay
-
-
-
-
- news.example.org
- bounce.example.org
- customer.example.net
-
-
-
- example.net
- selector1
- fail
- body hash did not verify
-
-
- bounce.example.org
- mfrom
- pass
- sender authorized
-
-
- mail-platform
-
-
-"""
+SAMPLE_RFC9990_XML = load_dmarc_fixture("rfc9990-treewalk-extension.xml")
def _make_zip(xml_content: str) -> bytes:
@@ -123,7 +58,7 @@ def test_upload_persists_rfc9990_optional_fields(client: TestClient, db_session)
)
assert response.status_code == 200
- report = db_session.query(DMARCReport).filter_by(report_id="2026-05-23-example.org").one()
+ report = db_session.query(DMARCReport).filter_by(report_id="fixture-rfc9990-treewalk").one()
assert report.domain.name == "example.org"
assert report.extra_contact_info == "https://example.test/dmarc"
assert report.generator == "ExampleRUA 2.0"
@@ -134,7 +69,7 @@ def test_upload_persists_rfc9990_optional_fields(client: TestClient, db_session)
assert report.failure_options == "1"
assert report.testing == "y"
assert report.discovery_method == "treewalk"
- assert "Multiple records ignored." in report.report_errors
+ assert "Multiple DMARC records were ignored before treewalk." in report.report_errors
assert "mx1.example.test" in report.report_extensions
record = db_session.query(ReportRecord).filter_by(report_id=report.id).one()
diff --git a/docs/index.md b/docs/index.md
index 696acbf..29a0b21 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -22,3 +22,5 @@ DMARQ is a full-stack DMARC monitoring platform designed to help organizations t
To get started with DMARQ, please see the [Getting Started](user_guide/getting_started.md) guide.
For installation instructions, check the [Docker Setup](deployment/docker.md) or [Manual Installation](deployment/manual.md) guides. Operators should use the [Operator Runbook](deployment/operations.md) for deployment modes, verification, upgrades, and rollback, and the [Troubleshooting Playbooks](deployment/troubleshooting.md) for ingestion, authentication, DNS, database, and notification failures. For production secrets, use [Secret Handling with 1Password](deployment/secrets.md). For database operations, use [Database Backup and Restore](deployment/backups.md). For upgrades, use the [Release Checklist](deployment/release-checklist.md).
+
+For aggregate-report parser support, known edge cases, and fixture guidance, see [DMARC Aggregate Format Compatibility](reference/dmarc-compatibility.md).
diff --git a/docs/milestones.md b/docs/milestones.md
index 860a709..5353a23 100644
--- a/docs/milestones.md
+++ b/docs/milestones.md
@@ -163,7 +163,7 @@ Exit criteria:
## Milestone 10: Forensic Report Support
-Status: In Progress
+Status: Complete
Goal: support DMARC RUF/forensic reports for individual failure investigation.
@@ -182,7 +182,7 @@ Exit criteria:
## Milestone 11: DMARC Format Compatibility (DMARCbis) and Standards Alignment
-Status: In Progress
+Status: Complete
Goal: keep DMARQ compatible with evolving DMARC report formats and nomenclature without breaking existing imports.
@@ -190,11 +190,8 @@ Delivered:
- Add parser compatibility for RFC 9990-style aggregate report namespaces, version detection, policy metadata, identifiers, override reasons, auth-result details, and namespaced extensions.
- Keep legacy RFC 7489-style reports backward compatible through fixture coverage.
- Persist and CSV-export newly introduced aggregate metadata with nullable, backward-safe database fields.
-
-Planned:
-- Update domain/source reporting to include new metadata where it improves operator actionability.
-- Add fixture-driven tests for representative real-world DMARCbis-style reports.
-- Update documentation to clarify supported formats and terminology.
+- Add a fixture-driven compatibility pack covering parser, upload, IMAP, and Gmail import paths with supported-format documentation.
+- Verify domain report views and CSV exports render fixture-backed DMARCbis-style imports correctly.
Exit criteria:
- A DMARCbis-style aggregate report can be imported via upload/IMAP/Gmail and renders correctly in dashboards and exports.
diff --git a/docs/reference/dmarc-compatibility.md b/docs/reference/dmarc-compatibility.md
new file mode 100644
index 0000000..d591f3e
--- /dev/null
+++ b/docs/reference/dmarc-compatibility.md
@@ -0,0 +1,55 @@
+# DMARC Aggregate Format Compatibility
+
+DMARQ imports DMARC aggregate reports from direct uploads, IMAP attachments, Gmail API attachments, and the Cloudflare Email Worker webhook. Compatibility is locked by the fixture pack in `backend/app/tests/fixtures/dmarc_aggregate`.
+
+## Supported Aggregate Inputs
+
+- Plain XML files with a `.xml` extension.
+- ZIP archives containing an XML report.
+- GZIP archives with `.gz` or `.gzip` extensions.
+- RFC 7489-compatible aggregate reports without XML namespaces.
+- Namespaced aggregate reports using `urn:ietf:params:xml:ns:dmarc-2.0`.
+- RFC 9990-style reports with optional metadata such as `version`, `generator`, `extra_contact_info`, repeated `error` values, `np`, `fo`, `testing`, `discovery_method`, `envelope_to`, policy override reasons, `human_result`, SPF `scope`, and namespaced extension elements.
+
+## Preserved Metadata
+
+Newer optional fields are parsed without changing the legacy response shape that existing screens use. When a database is configured, DMARQ also persists the optional report metadata, policy metadata, record identifiers, policy override reasons, and extension payloads so exports and future views can use them.
+
+CSV exports include the most useful aggregate metadata for operators:
+
+- `subdomain_policy`
+- `non_subdomain_policy`
+- `adkim`
+- `aspf`
+- `failure_options`
+- `testing`
+- `discovery_method`
+- `schema_version`
+- `report_variant`
+- `generator`
+
+## Known Edge Cases
+
+- Unknown namespaced extension fields are preserved as best-effort key/value data after namespace prefixes are stripped by the XML parser.
+- Malformed optional timestamps and counts use safe defaults so one bad optional value does not reject the whole report.
+- Reports with no `` elements import with a zero-count summary.
+- Unsupported attachments are skipped by IMAP and Gmail import paths and recorded in import details when stats are available.
+- For duplicate detection, DMARQ uses the domain and `report_id` pair. Fixture report IDs must remain unique within a single test import run.
+
+## Fixture Pack
+
+The current fixture pack covers:
+
+- `rfc7489-google.xml`: legacy no-namespace aggregate report.
+- `rfc9990-namespaced-legacy-fields.xml`: namespaced aggregate report that keeps legacy fields working.
+- `rfc9990-treewalk-extension.xml`: RFC 9990-style policy metadata, treewalk discovery, `envelope_to`, override reasons, auth-result details, and report/record extensions.
+- `rfc9990-multi-auth-overrides.xml`: multiple records, multiple DKIM auth results, policy override reasons, PSD-style discovery, and nested vendor extensions.
+
+The compatibility tests exercise every fixture through parser extraction, upload import, IMAP attachment import, and Gmail attachment import.
+
+## Adding Fixtures
+
+1. Add the XML file under `backend/app/tests/fixtures/dmarc_aggregate`.
+2. Add its expected metadata to `DMARC_COMPATIBILITY_FIXTURES` in `backend/app/tests/test_data.py`.
+3. Include a unique `report_id`, stable domain, expected variant, expected total count, and any policy fields that should be asserted.
+4. Run `pytest backend/app/tests/test_dmarc_compatibility_fixtures.py backend/app/tests/test_dmarc_parser.py backend/app/tests/test_reports_api.py`.