diff --git a/TODO.md b/TODO.md
index b1e090d..8eda2a7 100644
--- a/TODO.md
+++ b/TODO.md
@@ -70,7 +70,7 @@ have no working implementation in the codebase yet.
- [x] Forensic report parsing
- [ ] Failure sample analysis
- [x] PII redaction options
-- [ ] Detailed authentication failure views
+- [x] Detailed authentication failure views
### User Authentication & Multi-User Support
- **Documented in**: README.md ("Built-in authentication via FastAPI Users"),
diff --git a/backend/app/api/api_v1/endpoints/forensics.py b/backend/app/api/api_v1/endpoints/forensics.py
index 38691e8..9b768e0 100644
--- a/backend/app/api/api_v1/endpoints/forensics.py
+++ b/backend/app/api/api_v1/endpoints/forensics.py
@@ -129,6 +129,9 @@ async def upload_forensic_report(
@router.get("", response_model=ForensicListResponse)
async def list_forensic_reports(
domain: Optional[str] = Query(default=None),
+ source_ip: Optional[str] = Query(default=None),
+ auth_failure: Optional[str] = Query(default=None),
+ delivery_result: Optional[str] = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
@@ -141,6 +144,12 @@ async def list_forensic_reports(
query = query.outerjoin(Domain).filter(
(Domain.name == normalized) | (ForensicReport.reported_domain == normalized)
)
+ if source_ip:
+ query = query.filter(ForensicReport.source_ip == source_ip.strip())
+ if auth_failure:
+ query = query.filter(ForensicReport.auth_failure == auth_failure.strip().lower())
+ if delivery_result:
+ query = query.filter(ForensicReport.delivery_result == delivery_result.strip().lower())
total = query.count()
rows = (
diff --git a/backend/app/main.py b/backend/app/main.py
index abe8326..4865236 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -551,6 +551,22 @@ async def report_detail(request: Request, report_id: str):
return templates.TemplateResponse(request, "report_detail.html", {"report_id": report_id})
+@app.get("/forensics", response_class=HTMLResponse)
+async def forensic_reports(request: Request):
+ """View DMARC forensic authentication failure reports."""
+ return templates.TemplateResponse(request, "forensic_reports.html")
+
+
+@app.get("/forensics/{report_id}", response_class=HTMLResponse)
+async def forensic_report_detail(request: Request, report_id: int):
+ """View detailed information for a specific forensic report."""
+ return templates.TemplateResponse(
+ request,
+ "forensic_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/templates/forensic_report_detail.html b/backend/app/templates/forensic_report_detail.html
new file mode 100644
index 0000000..117ccbe
--- /dev/null
+++ b/backend/app/templates/forensic_report_detail.html
@@ -0,0 +1,166 @@
+{% extends "layouts/base.html" %}
+{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content %}
+
+{% block title %}DMARQ - Forensic Report Detail{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Forensic Investigation
+
+
+ •
+
+
+
+
+
+
+
+ {% call card() %}
+ {% call card_header() %}{% call card_title() %}Failure{% endcall %}{% endcall %}
+ {% call card_content() %}
{% endcall %}
+ {% endcall %}
+ {% call card() %}
+ {% call card_header() %}{% call card_title() %}Delivery{% endcall %}{% endcall %}
+ {% call card_content() %}
{% endcall %}
+ {% endcall %}
+ {% call card() %}
+ {% call card_header() %}{% call card_title() %}Source IP{% endcall %}{% endcall %}
+ {% call card_content() %}
{% endcall %}
+ {% endcall %}
+ {% call card() %}
+ {% call card_header() %}{% call card_title() %}Arrival{% endcall %}{% endcall %}
+ {% call card_content() %}
{% endcall %}
+ {% endcall %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+ {% call card_title() %}Message Identity{% endcall %}
+ {% call card_description() %}Redacted metadata extracted from the reported failure sample{% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+
+ {% endcall %}
+ {% endcall %}
+
+ {% call card() %}
+ {% call card_header() %}
+ {% call card_title() %}Authentication Results{% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+
+ {% endcall %}
+ {% endcall %}
+
+ {% call card() %}
+ {% call card_header() %}
+ {% call card_title() %}Feedback Headers{% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+
+
+ No additional feedback headers were included.
+
+
+
+
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/backend/app/templates/forensic_reports.html b/backend/app/templates/forensic_reports.html
new file mode 100644
index 0000000..cba5097
--- /dev/null
+++ b/backend/app/templates/forensic_reports.html
@@ -0,0 +1,268 @@
+{% extends "layouts/base.html" %}
+{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content %}
+{% from "components/ui/table.html" import table, thead, tbody, tr, th, td %}
+
+{% block title %}DMARQ - Forensic Reports{% endblock %}
+
+{% block content %}
+
+
+
+
Forensic Reports
+
Individual DMARC authentication failure reports
+
+
Privacy Settings
+
+
+
+ {% call card() %}
+ {% call card_header() %}{% call card_title() %}Incidents{% endcall %}{% endcall %}
+ {% call card_content() %}
+
+ {% endcall %}
+ {% endcall %}
+ {% call card() %}
+ {% call card_header() %}{% call card_title() %}DKIM Failures{% endcall %}{% endcall %}
+ {% call card_content() %}
+
+ {% endcall %}
+ {% endcall %}
+ {% call card() %}
+ {% call card_header() %}{% call card_title() %}SPF Failures{% endcall %}{% endcall %}
+ {% call card_content() %}
+
+ {% endcall %}
+ {% endcall %}
+ {% call card() %}
+ {% call card_header() %}{% call card_title() %}Rejected{% endcall %}{% endcall %}
+ {% call card_content() %}
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+ {% call card() %}
+ {% call card_header() %}
+ {% call card_title() %}Filters{% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+
+
+
+
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {% call card() %}
+ {% call card_header() %}
+ {% call card_title() %}Import Sample{% endcall %}
+ {% call card_description() %}Upload a forensic .eml or .txt file{% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+
+ {% endcall %}
+ {% endcall %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+
+
+ {% call card_title() %}Authentication Failures{% endcall %}
+ {% call card_description() %}
+ Showing of reports
+ {% endcall %}
+
+
+
+ {% endcall %}
+ {% call card_content() %}
+
+
+
+
+
+
+
+ {% call table() %}
+ {% call thead() %}
+ {% call tr() %}
+ {% call th() %}Arrival{% endcall %}
+ {% call th() %}Domain{% endcall %}
+ {% call th() %}Source IP{% endcall %}
+ {% call th() %}Failure{% endcall %}
+ {% call th() %}Result{% endcall %}
+ {% call th() %}Original From{% endcall %}
+ {% call th() %}Subject{% endcall %}
+ {% call th("text-right") %}Actions{% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% call tbody() %}
+
+
+ | No forensic reports match the current filters. |
+
+
+
+ {% call tr() %}
+ {% call td() %}{% endcall %}
+ {% call td() %}{% endcall %}
+ {% call td() %}{% endcall %}
+ {% call td() %}{% endcall %}
+ {% call td() %}{% endcall %}
+ {% call td() %}{% endcall %}
+ {% call td() %}{% endcall %}
+ {% call td("text-right") %}
+ Investigate
+ {% endcall %}
+ {% endcall %}
+
+ {% endcall %}
+ {% endcall %}
+
+ {% endcall %}
+ {% endcall %}
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/backend/app/templates/layouts/base.html b/backend/app/templates/layouts/base.html
index e699513..4d6ae64 100644
--- a/backend/app/templates/layouts/base.html
+++ b/backend/app/templates/layouts/base.html
@@ -38,6 +38,7 @@
Dashboard
Domains
Reports
+ Forensics
Upload
Mail Sources
Health
diff --git a/backend/app/tests/test_forensics_api.py b/backend/app/tests/test_forensics_api.py
index db14178..dc06592 100644
--- a/backend/app/tests/test_forensics_api.py
+++ b/backend/app/tests/test_forensics_api.py
@@ -1,5 +1,6 @@
import pytest
from fastapi import HTTPException
+from fastapi.testclient import TestClient
from sqlalchemy.exc import IntegrityError
from app.api.api_v1.endpoints import forensics as forensics_endpoint
@@ -63,6 +64,32 @@ def test_list_and_detail_forensic_reports(authed_client):
assert detail_response.json()["reported_domain"] == "example.com"
+def test_list_forensic_reports_filters_failure_fields(authed_client, db_session):
+ first = ForensicParser.parse_bytes(SAMPLE_FORENSIC_EMAIL)
+ second = dict(first)
+ second.update(
+ {
+ "report_id": "ruf-spf-filter-test",
+ "reported_domain": "example.net",
+ "source_ip": "198.51.100.77",
+ "auth_failure": "spf",
+ "delivery_result": "none",
+ }
+ )
+ save_forensic_report(db_session, first)
+ save_forensic_report(db_session, second)
+ db_session.commit()
+
+ response = authed_client.get(
+ "/api/v1/forensics?auth_failure=spf&delivery_result=none&source_ip=198.51.100.77"
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total"] == 1
+ assert data["reports"][0]["report_id"] == "ruf-spf-filter-test"
+
+
def test_forensic_api_applies_configured_redaction_policy(authed_client, db_session):
authed_client.post(
"/api/v1/forensics/upload",
@@ -175,6 +202,22 @@ def test_forensic_detail_returns_404(authed_client):
assert response.status_code == 404
+def test_forensic_html_pages_render():
+ from app.core.logto import SESSION_COOKIE, create_session_token # noqa: PLC0415
+ from app.main import app as main_app # noqa: PLC0415
+
+ cookies = {SESSION_COOKIE: create_session_token(user_id=1)}
+ with TestClient(main_app) as c:
+ list_response = c.get("/forensics", cookies=cookies)
+ detail_response = c.get("/forensics/123", cookies=cookies)
+
+ assert list_response.status_code == 200
+ assert "Forensic Reports" in list_response.text
+ assert "Authentication Failures" in list_response.text
+ assert detail_response.status_code == 200
+ assert "Forensic Investigation" in detail_response.text
+
+
def test_save_forensic_report_duplicate_and_invalid_domain_paths(db_session):
parsed = ForensicParser.parse_bytes(SAMPLE_FORENSIC_EMAIL)
parsed["feedback_headers"] = {"identity_alignment": "dkim"}
diff --git a/docs/milestones.md b/docs/milestones.md
index 662edc2..42d078d 100644
--- a/docs/milestones.md
+++ b/docs/milestones.md
@@ -174,9 +174,9 @@ Delivered:
- Configure forensic report redaction for balanced, domain-only, and strict views.
- Keep forensic reports out of aggregate report statistics and ReportStore rollups.
- Expose authenticated forensic upload/list/detail APIs.
+- Provide dedicated forensic report list/detail views for authentication failure investigation.
Planned:
-- Add a dedicated forensic report view.
- Add richer failure investigation workflows.
Exit criteria: