From 34d8a655a223fcf2cf87066af5cca03213697ee8 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Fri, 22 May 2026 21:21:39 +0200 Subject: [PATCH] feat: add domain report csv export --- backend/app/api/api_v1/endpoints/domains.py | 113 +++++++++++++++++- backend/app/templates/domain_details.html | 37 +++++- .../app/tests/test_domain_detail_endpoints.py | 65 ++++++++++ docs/development/roadmap.md | 2 +- docs/milestones.md | 2 +- docs/todo.md | 4 +- 6 files changed, 216 insertions(+), 7 deletions(-) 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 %} +
+
+ + + + + CSV + +
+
{% 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