Merge pull request #180 from christianlouis/codex/m11-compat-fixtures-docs

[codex] Add DMARC compatibility fixture pack
This commit is contained in:
Christian Krakau-Louis
2026-05-23 14:34:30 +02:00
committed by GitHub
11 changed files with 582 additions and 239 deletions
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply-dmarc-support@google.com</email>
<report_id>123456789</report_id>
<date_range>
<begin>1597449600</begin>
<end>1597535999</end>
</date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>none</p>
<sp>none</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>203.0.113.1</source_ip>
<count>2</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>example.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>example.com</domain>
<result>pass</result>
<selector>default</selector>
</dkim>
<spf>
<domain>example.com</domain>
<result>fail</result>
</spf>
</auth_results>
</record>
</feedback>
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8" ?>
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0" xmlns:vendor="https://reports.example.test/dmarc">
<version>1.0</version>
<report_metadata>
<org_name>Mailbox Provider</org_name>
<email>dmarc-reports@mailbox.example</email>
<extra_contact_info>mailto:dmarc-help@mailbox.example</extra_contact_info>
<report_id>fixture-rfc9990-multi-auth</report_id>
<date_range>
<begin>1779580800</begin>
<end>1779667199</end>
</date_range>
<generator>MailboxRUA 2026.05</generator>
</report_metadata>
<policy_published>
<domain>example.net</domain>
<discovery_method>psd</discovery_method>
<p>reject</p>
<sp>quarantine</sp>
<np>reject</np>
<fo>1:d:s</fo>
<adkim>r</adkim>
<aspf>s</aspf>
<pct>75</pct>
<testing>n</testing>
</policy_published>
<extension>
<vendor:provider>
<vendor:name>Mailbox Provider</vendor:name>
<vendor:region>us-east</vendor:region>
</vendor:provider>
</extension>
<record>
<row>
<source_ip>192.0.2.44</source_ip>
<count>10</count>
<policy_evaluated>
<disposition>reject</disposition>
<dkim>fail</dkim>
<spf>fail</spf>
<reason>
<type>forwarded</type>
<comment>forwarder modified body</comment>
</reason>
</policy_evaluated>
</row>
<identifiers>
<header_from>billing.example.net</header_from>
<envelope_from>bounce.example.net</envelope_from>
<envelope_to>customer.example.org</envelope_to>
</identifiers>
<auth_results>
<dkim>
<domain>example.net</domain>
<selector>mail</selector>
<result>fail</result>
<human_result>signature expired</human_result>
</dkim>
<dkim>
<domain>vendor.example</domain>
<selector>relay</selector>
<result>pass</result>
</dkim>
<spf>
<domain>bounce.example.net</domain>
<scope>mfrom</scope>
<result>fail</result>
<human_result>ip not authorized</human_result>
</spf>
</auth_results>
<vendor:category>transactional</vendor:category>
</record>
<record>
<row>
<source_ip>198.51.100.77</source_ip>
<count>5</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>news.example.net</header_from>
<envelope_from>mailer.example.net</envelope_from>
</identifiers>
<auth_results>
<dkim>
<domain>example.net</domain>
<selector>news</selector>
<result>pass</result>
</dkim>
<spf>
<domain>mailer.example.net</domain>
<scope>mfrom</scope>
<result>fail</result>
</spf>
</auth_results>
</record>
</feedback>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?>
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0">
<report_metadata>
<org_name>web.de</org_name>
<email>dmarc@web.de</email>
<report_id>987654321</report_id>
<date_range>
<begin>1597449600</begin>
<end>1597535999</end>
</date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>reject</p>
<sp>reject</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>198.51.100.5</source_ip>
<count>3</count>
<policy_evaluated>
<disposition>reject</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>example.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>example.com</domain>
<result>pass</result>
<selector>s1</selector>
</dkim>
<spf>
<domain>example.com</domain>
<result>pass</result>
</spf>
</auth_results>
</record>
</feedback>
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" ?>
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0" xmlns:vendor="https://reports.example.test/dmarc">
<version>1.0</version>
<report_metadata>
<org_name>Example Receiver</org_name>
<email>dmarc@example.test</email>
<extra_contact_info>https://example.test/dmarc</extra_contact_info>
<report_id>fixture-rfc9990-treewalk</report_id>
<date_range>
<begin>1779494400</begin>
<end>1779580799</end>
</date_range>
<error>Multiple DMARC records were ignored before treewalk.</error>
<generator>ExampleRUA 2.0</generator>
</report_metadata>
<policy_published>
<domain>example.org</domain>
<discovery_method>treewalk</discovery_method>
<p>quarantine</p>
<sp>reject</sp>
<np>none</np>
<fo>1</fo>
<adkim>s</adkim>
<aspf>r</aspf>
<testing>y</testing>
</policy_published>
<extension>
<vendor:receiver>mx1.example.test</vendor:receiver>
</extension>
<record>
<row>
<source_ip>2001:db8::1</source_ip>
<count>5</count>
<policy_evaluated>
<disposition>quarantine</disposition>
<dkim>fail</dkim>
<spf>pass</spf>
<reason>
<type>local_policy</type>
<comment>trusted relay</comment>
</reason>
</policy_evaluated>
</row>
<identifiers>
<header_from>news.example.org</header_from>
<envelope_from>bounce.example.org</envelope_from>
<envelope_to>customer.example.net</envelope_to>
</identifiers>
<auth_results>
<dkim>
<domain>example.net</domain>
<selector>selector1</selector>
<result>fail</result>
<human_result>body hash did not verify</human_result>
</dkim>
<spf>
<domain>bounce.example.org</domain>
<scope>mfrom</scope>
<result>pass</result>
<human_result>sender authorized</human_result>
</spf>
</auth_results>
<vendor:source>mail-platform</vendor:source>
</record>
</feedback>
+66 -94
View File
@@ -1,97 +1,69 @@
"""Shared test data for DMARC report tests."""
SAMPLE_XML = """\
<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply-dmarc-support@google.com</email>
<report_id>123456789</report_id>
<date_range>
<begin>1597449600</begin>
<end>1597535999</end>
</date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>none</p>
<sp>none</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>203.0.113.1</source_ip>
<count>2</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>example.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>example.com</domain>
<result>pass</result>
<selector>default</selector>
</dkim>
<spf>
<domain>example.com</domain>
<result>fail</result>
</spf>
</auth_results>
</record>
</feedback>
"""
from pathlib import Path
SAMPLE_XML_WITH_NAMESPACE = """\
<?xml version="1.0" encoding="UTF-8" ?>
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0">
<report_metadata>
<org_name>web.de</org_name>
<email>dmarc@web.de</email>
<report_id>987654321</report_id>
<date_range>
<begin>1597449600</begin>
<end>1597535999</end>
</date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>reject</p>
<sp>reject</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>198.51.100.5</source_ip>
<count>3</count>
<policy_evaluated>
<disposition>reject</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>example.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>example.com</domain>
<result>pass</result>
<selector>s1</selector>
</dkim>
<spf>
<domain>example.com</domain>
<result>pass</result>
</spf>
</auth_results>
</record>
</feedback>
"""
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")
@@ -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())
+3 -69
View File
@@ -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"""
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0"
xmlns:vendor="https://reports.example.test/dmarc">
<version>1.0</version>
<report_metadata>
<org_name>Example Receiver</org_name>
<email>dmarc@example.test</email>
<extra_contact_info>https://example.test/dmarc</extra_contact_info>
<report_id>2026-05-23-example.org</report_id>
<date_range>
<begin>1779494400</begin>
<end>1779580799</end>
</date_range>
<error>Multiple DMARC records were ignored before treewalk.</error>
<generator>ExampleRUA 2.0</generator>
</report_metadata>
<policy_published>
<domain>example.org</domain>
<discovery_method>treewalk</discovery_method>
<p>quarantine</p>
<sp>reject</sp>
<np>none</np>
<fo>1</fo>
<adkim>s</adkim>
<aspf>r</aspf>
<testing>y</testing>
</policy_published>
<extension>
<vendor:receiver>mx1.example.test</vendor:receiver>
</extension>
<record>
<row>
<source_ip>2001:db8::1</source_ip>
<count>5</count>
<policy_evaluated>
<disposition>quarantine</disposition>
<dkim>fail</dkim>
<spf>pass</spf>
<reason>
<type>local_policy</type>
<comment>trusted relay</comment>
</reason>
</policy_evaluated>
</row>
<identifiers>
<header_from>news.example.org</header_from>
<envelope_from>bounce.example.org</envelope_from>
<envelope_to>customer.example.net</envelope_to>
</identifiers>
<auth_results>
<dkim>
<domain>example.net</domain>
<selector>selector1</selector>
<result>fail</result>
<human_result>body hash did not verify</human_result>
</dkim>
<spf>
<domain>bounce.example.org</domain>
<scope>mfrom</scope>
<result>pass</result>
<human_result>sender authorized</human_result>
</spf>
</auth_results>
<vendor:source>mail-platform</vendor:source>
</record>
</feedback>
"""
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"}
+4 -69
View File
@@ -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 = """
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0" xmlns:vendor="https://reports.example.test/dmarc">
<version>1.0</version>
<report_metadata>
<org_name>Example Receiver</org_name>
<email>dmarc@example.test</email>
<extra_contact_info>https://example.test/dmarc</extra_contact_info>
<report_id>2026-05-23-example.org</report_id>
<date_range>
<begin>1779494400</begin>
<end>1779580799</end>
</date_range>
<error>Multiple records ignored.</error>
<generator>ExampleRUA 2.0</generator>
</report_metadata>
<policy_published>
<domain>example.org</domain>
<discovery_method>treewalk</discovery_method>
<p>quarantine</p>
<sp>reject</sp>
<np>none</np>
<fo>1</fo>
<adkim>s</adkim>
<aspf>r</aspf>
<testing>y</testing>
</policy_published>
<extension>
<vendor:receiver>mx1.example.test</vendor:receiver>
</extension>
<record>
<row>
<source_ip>2001:db8::1</source_ip>
<count>5</count>
<policy_evaluated>
<disposition>quarantine</disposition>
<dkim>fail</dkim>
<spf>pass</spf>
<reason>
<type>local_policy</type>
<comment>trusted relay</comment>
</reason>
</policy_evaluated>
</row>
<identifiers>
<header_from>news.example.org</header_from>
<envelope_from>bounce.example.org</envelope_from>
<envelope_to>customer.example.net</envelope_to>
</identifiers>
<auth_results>
<dkim>
<domain>example.net</domain>
<selector>selector1</selector>
<result>fail</result>
<human_result>body hash did not verify</human_result>
</dkim>
<spf>
<domain>bounce.example.org</domain>
<scope>mfrom</scope>
<result>pass</result>
<human_result>sender authorized</human_result>
</spf>
</auth_results>
<vendor:source>mail-platform</vendor:source>
</record>
</feedback>
"""
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()
+2
View File
@@ -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).
+4 -7
View File
@@ -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.
+55
View File
@@ -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 `<record>` 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`.