diff --git a/backend/app/api/api_v1/endpoints/reports.py b/backend/app/api/api_v1/endpoints/reports.py index 521a41a..2e4618a 100644 --- a/backend/app/api/api_v1/endpoints/reports.py +++ b/backend/app/api/api_v1/endpoints/reports.py @@ -1,5 +1,5 @@ import logging -from typing import List +from typing import Any, Dict, List, Optional from fastapi import APIRouter, File, HTTPException, UploadFile, status from pydantic import BaseModel @@ -374,3 +374,112 @@ async def delete_report(domain: str, report_id: str): success=True, message=f"Report '{report_id}' for domain '{domain}' deleted successfully.", ) + + +class ReportRecordDetail(BaseModel): + """Detailed record from a DMARC report""" + + source_ip: str + count: int + disposition: str + dkim_result: str + spf_result: str + header_from: str + spf: Optional[List[Dict[str, Any]]] = None + dkim: Optional[List[Dict[str, Any]]] = None + + +class ReportPolicyDetail(BaseModel): + """Published policy from a DMARC report""" + + p: str + sp: str = "" + pct: str = "100" + + +class ReportSummaryDetail(BaseModel): + """Summary statistics for a DMARC report""" + + total_count: int + passed_count: int + failed_count: int + pass_rate: float + + +class ReportDetail(BaseModel): + """Full detail of a single DMARC report""" + + report_id: str + org_name: str + email: str + domain: str + begin_date: str + end_date: str + begin_timestamp: int + end_timestamp: int + policy: ReportPolicyDetail + records: List[ReportRecordDetail] + summary: ReportSummaryDetail + + +@router.get("/{report_id}", response_model=ReportDetail) +async def get_report_by_id(report_id: str): + """ + Get full details for a single DMARC report by its report ID. + """ + store = ReportStore.get_instance() + report = store.get_report_by_id(report_id) + + if report is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Report '{report_id}' not found.", + ) + + # Normalize the policy field + policy_val = report.get("policy", {}) + if isinstance(policy_val, str): + policy_val = {"p": policy_val, "sp": "", "pct": "100"} + policy_detail = ReportPolicyDetail( + p=policy_val.get("p", "none"), + sp=policy_val.get("sp", ""), + pct=str(policy_val.get("pct", "100")), + ) + + # Normalize records + record_details = [] + for rec in report.get("records", []): + record_details.append( + ReportRecordDetail( + source_ip=rec.get("source_ip", ""), + count=rec.get("count", 0), + disposition=rec.get("disposition", "none"), + dkim_result=rec.get("dkim_result", ""), + spf_result=rec.get("spf_result", ""), + header_from=rec.get("header_from", ""), + spf=rec.get("spf") if isinstance(rec.get("spf"), list) else None, + dkim=rec.get("dkim") if isinstance(rec.get("dkim"), list) else None, + ) + ) + + raw_summary = report.get("summary", {}) + summary_detail = ReportSummaryDetail( + total_count=raw_summary.get("total_count", 0), + passed_count=raw_summary.get("passed_count", 0), + failed_count=raw_summary.get("failed_count", 0), + pass_rate=raw_summary.get("pass_rate", 0.0), + ) + + return ReportDetail( + report_id=report.get("report_id", ""), + org_name=report.get("org_name", ""), + email=report.get("email", ""), + domain=report.get("domain", ""), + begin_date=str(report.get("begin_date", "")), + end_date=str(report.get("end_date", "")), + begin_timestamp=report.get("begin_timestamp", 0), + end_timestamp=report.get("end_timestamp", 0), + policy=policy_detail, + records=record_details, + summary=summary_detail, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 8d41a04..8312082 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -408,6 +408,14 @@ async def reports(request: Request): return templates.TemplateResponse(request, "reports.html") +@app.get("/reports/{report_id}", response_class=HTMLResponse) +async def report_detail(request: Request, report_id: str): + """View detailed information for a specific DMARC report""" + return templates.TemplateResponse( + request, "report_detail.html", {"report_id": report_id} + ) + + @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): return templates.TemplateResponse(request, "settings.html") diff --git a/backend/app/services/report_store.py b/backend/app/services/report_store.py index 4f66cad..d6a13e0 100644 --- a/backend/app/services/report_store.py +++ b/backend/app/services/report_store.py @@ -147,6 +147,22 @@ class ReportStore: """ return self.domain_summary + def get_report_by_id(self, report_id: str) -> Optional[Dict[str, Any]]: + """ + Find a report by its report_id across all domains. + + Args: + report_id: Report identifier from the DMARC report metadata + + Returns: + The report dictionary if found, None otherwise + """ + for reports in self.domain_reports.values(): + for report in reports: + if report.get("report_id") == report_id: + return report + return None + def get_domain_reports(self, domain: str, limit: Optional[int] = None) -> List[Dict[str, Any]]: """ Get all reports for a domain diff --git a/backend/app/templates/report_detail.html b/backend/app/templates/report_detail.html new file mode 100644 index 0000000..c623f6a --- /dev/null +++ b/backend/app/templates/report_detail.html @@ -0,0 +1,276 @@ +{% extends "layouts/base.html" %} +{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content %} +{% from "components/ui/button.html" import button, button_link %} +{% from "components/ui/table.html" import table, thead, tbody, tr, th, td %} + +{% block title %}DMARQ - Report Detail{% endblock %} + +{% block content %} +
Report ID
+ +Organization
+ +Domain
+ +Period Start
+ +Period End
+ +Policy (p)
+ +Subdomain Policy (sp)
+ +Percentage (pct)
+ +