diff --git a/backend/app/api/api_v1/endpoints/domains.py b/backend/app/api/api_v1/endpoints/domains.py
index 87aa23a..f1b54ec 100644
--- a/backend/app/api/api_v1/endpoints/domains.py
+++ b/backend/app/api/api_v1/endpoints/domains.py
@@ -1,10 +1,13 @@
import asyncio
+import csv
+import io
import ipaddress
import logging
-from datetime import datetime, timezone
+from datetime import date, datetime, timezone
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
+from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -443,6 +446,85 @@ async def get_domain_reports(
return DomainReportsResponse(reports=report_entries, compliance_timeline=timeline)
+@router.get("/{domain_id}/reports/export")
+async def export_domain_reports(
+ domain_id: str = Path(..., title="The domain ID or name"),
+ start_date: Optional[date] = Query(None, title="Start date for exported reports"),
+ end_date: Optional[date] = Query(None, title="End date for exported reports"),
+ db: Session = Depends(get_db),
+):
+ """
+ Export DMARC report summaries for a specific domain as CSV.
+ """
+ if start_date and end_date and start_date > end_date:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="start_date must be on or before end_date",
+ )
+
+ store = ReportStore.get_instance()
+ hydrate_report_store_from_db(db, store)
+
+ if domain_id not in store.get_domains():
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Domain not found",
+ )
+
+ reports = [
+ report
+ for report in store.get_domain_reports(domain_id, limit=10000)
+ if _report_in_export_range(report, start_date, end_date)
+ ]
+
+ output = io.StringIO()
+ writer = csv.writer(output)
+ writer.writerow(
+ [
+ "domain",
+ "report_id",
+ "org_name",
+ "begin_date",
+ "end_date",
+ "total_emails",
+ "passed",
+ "failed",
+ "pass_rate",
+ "policy",
+ ]
+ )
+
+ for report in reports:
+ summary = report.get("summary", {})
+ total = int(summary.get("total_count", report.get("total_count", 0)) or 0)
+ passed = int(summary.get("passed_count", report.get("passed_count", 0)) or 0)
+ failed = int(summary.get("failed_count", report.get("failed_count", 0)) or 0)
+ policy = report.get("policy", "none")
+ if isinstance(policy, dict):
+ policy = policy.get("p", "none")
+ writer.writerow(
+ [
+ domain_id,
+ report.get("report_id", "unknown"),
+ report.get("org_name", "Unknown Organization"),
+ _format_report_date(report.get("begin_timestamp") or report.get("begin_date")),
+ _format_report_date(report.get("end_timestamp") or report.get("end_date")),
+ total,
+ passed,
+ failed,
+ report.get("pass_rate", 0.0),
+ policy,
+ ]
+ )
+
+ filename = f"{domain_id.replace('/', '_')}-dmarc-reports.csv"
+ return Response(
+ content=output.getvalue(),
+ media_type="text/csv",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
def _build_compliance_timeline(store: ReportStore, domain: str) -> List[TimelinePoint]:
"""
Build a compliance timeline from actual report data stored in ReportStore.
@@ -501,6 +583,35 @@ def _build_compliance_timeline(store: ReportStore, domain: str) -> List[Timeline
return timeline
+def _report_in_export_range(
+ report: Dict[str, Any], start_date: Optional[date], end_date: Optional[date]
+) -> bool:
+ report_date = _report_date(report.get("begin_timestamp") or report.get("begin_date"))
+ if report_date is None:
+ return False
+ if start_date and report_date < start_date:
+ return False
+ if end_date and report_date > end_date:
+ return False
+ return True
+
+
+def _report_date(value: Any) -> Optional[date]:
+ if isinstance(value, (int, float)) and value > 0:
+ return datetime.fromtimestamp(value, tz=timezone.utc).date()
+ if isinstance(value, str):
+ try:
+ return datetime.fromisoformat(value).date()
+ except (ValueError, TypeError):
+ return None
+ return None
+
+
+def _format_report_date(value: Any) -> str:
+ report_date = _report_date(value)
+ return report_date.isoformat() if report_date else ""
+
+
def _spf_fix_hint(ip: str, spf_result: str, failed_count: int = 0) -> Optional[str]:
"""Return a copy-paste SPF mechanism (e.g. ``ip4:1.2.3.4``) for a failing IP.
diff --git a/backend/app/templates/domain_details.html b/backend/app/templates/domain_details.html
index fbb38a0..63cff89 100644
--- a/backend/app/templates/domain_details.html
+++ b/backend/app/templates/domain_details.html
@@ -409,7 +409,29 @@
{% call card() %}
{% call card_header() %}
- {% call card_title() %}Recent Reports{% endcall %}
+
+
+ {% call card_title() %}Recent Reports{% endcall %}
+
+
+
{% call card_description() %}
Latest DMARC reports received for this domain
{% endcall %}
@@ -498,7 +520,9 @@ function domainDetailsApp(domainId) {
complianceChart: null,
filters: {
dateRange: '30',
- sourceFilter: ''
+ sourceFilter: '',
+ exportStartDate: '',
+ exportEndDate: ''
},
init() {
@@ -522,6 +546,15 @@ function domainDetailsApp(domainId) {
});
},
+ get exportReportsUrl() {
+ const params = new URLSearchParams();
+ if (this.filters.exportStartDate) params.set('start_date', this.filters.exportStartDate);
+ if (this.filters.exportEndDate) params.set('end_date', this.filters.exportEndDate);
+ const query = params.toString();
+ const baseUrl = `/api/v1/domains/${encodeURIComponent(this.domainId)}/reports/export`;
+ return query ? `${baseUrl}?${query}` : baseUrl;
+ },
+
get dkimLiveText() {
if (!this.dns.dkim) return 'No DKIM record found for configured selectors';
if (this.dns.dkimSelectors && this.dns.dkimSelectors.length > 0) {
diff --git a/backend/app/tests/test_domain_detail_endpoints.py b/backend/app/tests/test_domain_detail_endpoints.py
index 10529dc..bcb72b5 100644
--- a/backend/app/tests/test_domain_detail_endpoints.py
+++ b/backend/app/tests/test_domain_detail_endpoints.py
@@ -8,6 +8,9 @@ the Pydantic response models, including the policy-dict extraction and
the use of begin_timestamp/end_timestamp integers for date fields.
"""
+import csv
+from io import StringIO
+
import pytest
from fastapi.testclient import TestClient
@@ -113,6 +116,68 @@ def test_get_domain_reports_unknown_domain_returns_404(client: TestClient):
assert response.status_code == 404
+# ---------------------------------------------------------------------------
+# GET /api/v1/domains/{domain_id}/reports/export
+# ---------------------------------------------------------------------------
+
+
+def test_export_domain_reports_returns_csv(seeded_client: TestClient):
+ """CSV export includes report summary rows for the requested domain."""
+ response = seeded_client.get(f"/api/v1/domains/{DOMAIN}/reports/export")
+
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/csv")
+ assert "attachment" in response.headers["content-disposition"]
+
+ rows = list(csv.DictReader(StringIO(response.text)))
+ assert len(rows) == 1
+ assert rows[0]["domain"] == DOMAIN
+ assert rows[0]["report_id"] == "rpt-dict-policy"
+ assert rows[0]["begin_date"] == "2020-08-15"
+ assert rows[0]["total_emails"] == "10"
+ assert rows[0]["passed"] == "10"
+ assert rows[0]["failed"] == "0"
+ assert rows[0]["policy"] == "reject"
+
+
+def test_export_domain_reports_filters_by_date_range(client: TestClient):
+ """CSV export only includes reports inside the requested date range."""
+ ReportStore.get_instance().add_report(REPORT_DICT_POLICY)
+ ReportStore.get_instance().add_report(
+ {
+ **REPORT_STR_POLICY,
+ "report_id": "rpt-next-day",
+ "begin_date": "2020-08-16T00:00:00",
+ "end_date": "2020-08-16T23:59:59",
+ "begin_timestamp": 1597536000,
+ "end_timestamp": 1597622399,
+ }
+ )
+
+ response = client.get(
+ f"/api/v1/domains/{DOMAIN}/reports/export?start_date=2020-08-16&end_date=2020-08-16"
+ )
+
+ assert response.status_code == 200
+ rows = list(csv.DictReader(StringIO(response.text)))
+ assert [row["report_id"] for row in rows] == ["rpt-next-day"]
+
+
+def test_export_domain_reports_rejects_invalid_date_order(seeded_client: TestClient):
+ """CSV export validates that the start date is not after the end date."""
+ response = seeded_client.get(
+ f"/api/v1/domains/{DOMAIN}/reports/export?start_date=2020-08-17&end_date=2020-08-16"
+ )
+
+ assert response.status_code == 422
+
+
+def test_export_domain_reports_unknown_domain_returns_404(client: TestClient):
+ """Returns 404 when exporting a domain with no reports."""
+ response = client.get("/api/v1/domains/no-such-domain.example.com/reports/export")
+ assert response.status_code == 404
+
+
# ---------------------------------------------------------------------------
# GET /api/v1/domains/{domain_id}/sources
# ---------------------------------------------------------------------------
diff --git a/docs/development/roadmap.md b/docs/development/roadmap.md
index 09baab9..bc2694c 100644
--- a/docs/development/roadmap.md
+++ b/docs/development/roadmap.md
@@ -42,13 +42,13 @@ Objective: turn parsed DMARC data into administrator-friendly reports.
Priority tasks:
- Add "what changed" summaries for newly observed senders and sudden compliance drops.
-- Add exportable reports for a domain and date range.
- Add actionable recommendations for common SPF, DKIM, and DMARC failure patterns.
Delivered:
- Dashboard time-series charts show daily mail volume, compliance rate, and failure rate.
- Top sending sources show DMARC, SPF, and DKIM pass/fail breakdowns on the dashboard.
- Per-domain timelines include daily volume, pass, fail, compliance-rate, and failure-rate rollups.
+- Domain reports can be exported to CSV for a selected date range.
Quality bar:
- A domain owner can understand who sends mail as their domain, which sources fail, and what to fix next.
diff --git a/docs/milestones.md b/docs/milestones.md
index 5475e7b..b2a45ec 100644
--- a/docs/milestones.md
+++ b/docs/milestones.md
@@ -81,9 +81,9 @@ Delivered:
- Dashboard trend charts for volume, compliance rate, and failure rate.
- Top sender/source reports with pass/fail breakdowns.
- Per-domain report timeline and daily rollups.
+- Exportable reports for a selected domain and date range.
Planned:
-- Exportable reports for a selected domain and date range.
- Clear recommendations for common cases: unknown source, SPF-only pass, DKIM-only pass, full fail, and policy not enforced.
Exit criteria:
diff --git a/docs/todo.md b/docs/todo.md
index 85b5f2b..99cbe2a 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -142,7 +142,7 @@ This file tracks the specific implementation tasks for each milestone of the DMA
- [ ] Add alert status summary (for later integration)
### Historical Data
-- [ ] Implement date range filtering
+- [x] Implement date range filtering
- [ ] Create historical trend analysis
- [ ] Add data aggregation for different time periods
- [ ] Implement data comparison features
@@ -151,7 +151,7 @@ This file tracks the specific implementation tasks for each milestone of the DMA
- [x] Add per-domain daily rollups
- [x] Add sender/source pass/fail totals
- [ ] Add newly observed source detection
-- [ ] Add exportable domain reports
+- [x] Add exportable domain reports
- [ ] Add actionable recommendations for common DMARC failure patterns
## Future Milestones