feat: add domain report csv export

This commit is contained in:
Christian Krakau-Louis
2026-05-22 21:21:39 +02:00
parent dad6f16f82
commit 34d8a655a2
6 changed files with 216 additions and 7 deletions
+112 -1
View File
@@ -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.
+35 -2
View File
@@ -409,7 +409,29 @@
<!-- Recent Reports -->
{% call card() %}
{% call card_header() %}
{% call card_title() %}Recent Reports{% endcall %}
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
{% call card_title() %}Recent Reports{% endcall %}
</div>
<div class="flex flex-wrap items-center gap-2">
<input
x-model="filters.exportStartDate"
type="date"
class="input input-sm input-bordered"
aria-label="Export start date"
>
<input
x-model="filters.exportEndDate"
type="date"
class="input input-sm input-bordered"
aria-label="Export end date"
>
<a :href="exportReportsUrl" class="btn btn-sm btn-outline">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-1"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
CSV
</a>
</div>
</div>
{% 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) {
@@ -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
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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