feat: add source recommendations

This commit is contained in:
Christian Krakau-Louis
2026-05-22 21:30:41 +02:00
parent c11ba15611
commit 364d517e07
6 changed files with 257 additions and 37 deletions
+114 -1
View File
@@ -90,6 +90,16 @@ class ReportEntry(BaseModel):
policy: str
class SourceRecommendation(BaseModel):
"""Actionable recommendation for a sending source"""
type: str
severity: str
title: str
detail: str
action: str
class SourceEntry(BaseModel):
"""Summary of a sending source"""
@@ -108,6 +118,7 @@ class SourceEntry(BaseModel):
disposition_counts: Dict[str, int] = Field(default_factory=dict)
hostname: Optional[str] = None
spf_fix_hint: Optional[str] = None
recommendations: List[SourceRecommendation] = Field(default_factory=list)
class DomainReportsResponse(BaseModel):
@@ -627,6 +638,106 @@ def _spf_fix_hint(ip: str, spf_result: str, failed_count: int = 0) -> Optional[s
return None
def _source_recommendations(
ip: str,
source: Dict[str, Any],
hostname: Optional[str],
spf_fix_hint: Optional[str],
) -> List[SourceRecommendation]:
"""Build clear next steps for common DMARC source patterns."""
spf_result = source.get("spf_result", "unknown")
dkim_result = source.get("dkim_result", "unknown")
dmarc_result = source.get("dmarc_result") or (
"pass" if spf_result == "pass" or dkim_result == "pass" else "fail"
)
disposition = source.get("disposition", "none")
disposition_counts = source.get("disposition_counts", {}) or {}
dmarc_failed = source.get("dmarc_fail_count", 0) > 0 or dmarc_result == "fail"
dmarc_passed = source.get("dmarc_pass_count", 0) > 0 or dmarc_result == "pass"
recommendations: List[SourceRecommendation] = []
if not hostname and dmarc_failed:
recommendations.append(
SourceRecommendation(
type="unknown_source",
severity="warning",
title="Unknown sending source",
detail=(
"No reverse DNS name was found for this IP, so treat it as unrecognized "
"until you confirm who owns it."
),
action=(
"Confirm whether this server should send mail for this domain before "
"authorizing it in SPF or DKIM."
),
)
)
if spf_result == "pass" and dkim_result in {"fail", "mixed", "unknown", "none"} and dmarc_passed:
recommendations.append(
SourceRecommendation(
type="spf_only_pass",
severity="info",
title="SPF-only DMARC pass",
detail="DMARC is passing through SPF, but DKIM is not reliably passing for this source.",
action=(
"Enable DKIM signing for this sending service so messages keep passing "
"if SPF alignment changes."
),
)
)
if dkim_result == "pass" and spf_result in {"fail", "mixed", "unknown", "none"} and dmarc_passed:
action = "Authorize this service in SPF, or confirm SPF is intentionally handled elsewhere."
if spf_fix_hint:
action = f"Add {spf_fix_hint} to your SPF record if this service is legitimate."
recommendations.append(
SourceRecommendation(
type="dkim_only_pass",
severity="info",
title="DKIM-only DMARC pass",
detail="DMARC is passing through DKIM, but SPF is not reliably passing for this source.",
action=action,
)
)
if spf_result == "fail" and dkim_result == "fail" and dmarc_failed:
action = (
"Do not authorize this source until you confirm it is legitimate; then configure "
"both SPF authorization and DKIM signing."
)
if spf_fix_hint:
action = (
f"If legitimate, add {spf_fix_hint} to SPF and enable DKIM signing for this service."
)
recommendations.append(
SourceRecommendation(
type="full_fail",
severity="error",
title="Full DMARC failure",
detail="Neither SPF nor DKIM is passing, so this mail fails DMARC.",
action=action,
)
)
if dmarc_failed and (disposition == "none" or disposition_counts.get("none", 0) > 0):
recommendations.append(
SourceRecommendation(
type="policy_not_enforced",
severity="warning",
title="Policy not enforced",
detail="Some failed mail was accepted because the applied DMARC disposition was none.",
action=(
"After legitimate sources are passing consistently, move the domain policy "
"toward quarantine or reject."
),
)
)
return recommendations
async def _safe_ptr_lookup(provider: Any, ip: str, timeout: float = 3.0) -> Optional[str]:
"""Perform a PTR lookup for *ip*, returning ``None`` on any error or timeout."""
try:
@@ -670,6 +781,7 @@ async def get_domain_sources(
ip = source.get("source_ip", "unknown")
spf_result = source.get("spf_result", "unknown")
dkim_result = source.get("dkim_result", "unknown")
spf_fix_hint = _spf_fix_hint(ip, spf_result, source.get("spf_fail_count", 0))
source_entries.append(
SourceEntry(
ip=ip,
@@ -687,7 +799,8 @@ async def get_domain_sources(
dmarc_fail_count=source.get("dmarc_fail_count", 0),
disposition_counts=source.get("disposition_counts", {}),
hostname=hostname,
spf_fix_hint=_spf_fix_hint(ip, spf_result, source.get("spf_fail_count", 0)),
spf_fix_hint=spf_fix_hint,
recommendations=_source_recommendations(ip, source, hostname, spf_fix_hint),
)
)
+38 -32
View File
@@ -252,7 +252,7 @@
{% call th() %}DKIM{% endcall %}
{% call th() %}DMARC{% endcall %}
{% call th() %}Disposition{% endcall %}
{% call th() %}Fix{% endcall %}
{% call th() %}Recommendations{% endcall %}
{% endcall %}
{% endcall %}
{% call tbody() %}
@@ -364,38 +364,38 @@
</template>
{% endcall %}
{% call td() %}
<template x-if="source.spf_fix_hint">
<div x-data="{ open: false }" class="relative">
<button
@click="open = !open"
class="btn btn-xs btn-warning"
title="Show SPF fix suggestion"
>
Fix SPF
</button>
<div
x-show="open"
@click.outside="open = false"
class="absolute right-0 z-20 mt-1 w-72 bg-base-100 border border-base-300 rounded-lg shadow-lg p-3 text-sm"
>
<p class="font-semibold mb-1">SPF Fix Suggestion</p>
<p class="text-xs text-muted-foreground mb-2">
Add the following mechanism to your SPF TXT record to authorize this server:
</p>
<div class="flex items-center gap-2 bg-base-200 rounded px-2 py-1">
<code class="flex-1 font-mono text-xs" x-text="source.spf_fix_hint"></code>
<button
@click="navigator.clipboard.writeText(source.spf_fix_hint)"
class="btn btn-xs btn-ghost"
title="Copy to clipboard"
>
<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"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
</button>
<template x-if="!source.recommendations || source.recommendations.length === 0">
<span class="text-xs text-muted-foreground">No action</span>
</template>
<template x-if="source.recommendations && source.recommendations.length > 0">
<div class="space-y-2 min-w-72">
<template x-for="recommendation in source.recommendations" :key="recommendation.type">
<div class="rounded-md border border-base-300 bg-base-100 p-2">
<div class="flex items-start gap-2">
<span
class="mt-0.5 inline-flex h-2.5 w-2.5 flex-none rounded-full"
:class="recommendationSeverityClass(recommendation.severity)"
></span>
<div class="min-w-0">
<p class="text-xs font-semibold" x-text="recommendation.title"></p>
<p class="mt-1 text-xs text-muted-foreground" x-text="recommendation.detail"></p>
<p class="mt-1 text-xs" x-text="recommendation.action"></p>
<template x-if="recommendation.type === 'dkim_only_pass' && source.spf_fix_hint">
<div class="mt-2 flex items-center gap-2 rounded bg-base-200 px-2 py-1">
<code class="flex-1 font-mono text-xs" x-text="source.spf_fix_hint"></code>
<button
@click="navigator.clipboard.writeText(source.spf_fix_hint)"
class="btn btn-xs btn-ghost"
title="Copy SPF mechanism"
>
Copy
</button>
</div>
</template>
</div>
</div>
</div>
<p class="text-xs text-muted-foreground mt-2">
Example: <code class="font-mono" x-text="'v=spf1 ' + source.spf_fix_hint + ' ~all'"></code>
</p>
</div>
</template>
</div>
</template>
{% endcall %}
@@ -563,6 +563,12 @@ function domainDetailsApp(domainId) {
return 'Verified';
},
recommendationSeverityClass(severity) {
if (severity === 'error') return 'bg-red-500';
if (severity === 'warning') return 'bg-yellow-500';
return 'bg-blue-500';
},
async fetchDomainStats() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/stats`);
@@ -14,6 +14,7 @@ from io import StringIO
import pytest
from fastapi.testclient import TestClient
from app.api.api_v1.endpoints import domains as domains_endpoint
from app.services.report_store import ReportStore
# ---------------------------------------------------------------------------
@@ -242,6 +243,108 @@ def test_get_domain_sources_returns_rollup_counts(client: TestClient):
assert source["disposition_counts"] == {"none": 4, "quarantine": 6}
def test_get_domain_sources_returns_recommendations(client: TestClient, monkeypatch: pytest.MonkeyPatch):
"""Endpoint includes actionable guidance for common failure patterns."""
async def fake_ptr_lookup(_provider, _ip, timeout=3.0): # pylint: disable=unused-argument
return "sender.example.net"
monkeypatch.setattr(domains_endpoint, "_safe_ptr_lookup", fake_ptr_lookup)
report = {
**REPORT_DICT_POLICY,
"report_id": "rpt-full-fail",
"records": [
{
"source_ip": "192.0.2.10",
"count": 3,
"disposition": "none",
"dkim_result": "fail",
"spf_result": "fail",
"header_from": DOMAIN,
}
],
"summary": {"total_count": 3, "passed_count": 0, "failed_count": 3},
}
ReportStore.get_instance().add_report(report)
response = client.get(f"/api/v1/domains/{DOMAIN}/sources")
assert response.status_code == 200
source = response.json()["sources"][0]
recommendation_types = {item["type"] for item in source["recommendations"]}
assert recommendation_types == {"full_fail", "policy_not_enforced"}
assert source["spf_fix_hint"] == "ip4:192.0.2.10"
def test_source_recommendations_cover_common_cases():
"""Recommendation builder handles the milestone source patterns."""
cases = [
(
{
"source_ip": "192.0.2.20",
"spf_result": "pass",
"dkim_result": "fail",
"dmarc_result": "pass",
"dmarc_pass_count": 8,
"dmarc_fail_count": 0,
"disposition": "none",
},
"mail.example.net",
None,
{"spf_only_pass"},
),
(
{
"source_ip": "192.0.2.21",
"spf_result": "fail",
"dkim_result": "pass",
"dmarc_result": "pass",
"dmarc_pass_count": 8,
"dmarc_fail_count": 0,
"disposition": "none",
},
"mail.example.net",
"ip4:192.0.2.21",
{"dkim_only_pass"},
),
(
{
"source_ip": "192.0.2.22",
"spf_result": "fail",
"dkim_result": "fail",
"dmarc_result": "fail",
"dmarc_pass_count": 0,
"dmarc_fail_count": 8,
"disposition": "none",
"disposition_counts": {"none": 8},
},
"mail.example.net",
"ip4:192.0.2.22",
{"full_fail", "policy_not_enforced"},
),
(
{
"source_ip": "192.0.2.23",
"spf_result": "fail",
"dkim_result": "fail",
"dmarc_result": "fail",
"dmarc_pass_count": 0,
"dmarc_fail_count": 8,
"disposition": "quarantine",
},
None,
"ip4:192.0.2.23",
{"unknown_source", "full_fail"},
),
]
for source, hostname, spf_fix_hint, expected_types in cases:
recommendations = domains_endpoint._source_recommendations( # pylint: disable=protected-access
source["source_ip"], source, hostname, spf_fix_hint
)
assert {item.type for item in recommendations} == expected_types
def test_get_domain_sources_days_param_accepted(seeded_client: TestClient):
"""The 'days' query parameter is accepted without raising a TypeError."""
response = seeded_client.get(f"/api/v1/domains/{DOMAIN}/sources?days=7")
+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 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.
- Source reports include actionable recommendations for unknown sources, SPF-only passes, DKIM-only passes, full failures, and unenforced policies.
Quality bar:
- A domain owner can understand who sends mail as their domain, which sources fail, and what to fix next.
-2
View File
@@ -82,8 +82,6 @@ Delivered:
- 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:
- Clear recommendations for common cases: unknown source, SPF-only pass, DKIM-only pass, full fail, and policy not enforced.
Exit criteria:
+1 -1
View File
@@ -152,7 +152,7 @@ This file tracks the specific implementation tasks for each milestone of the DMA
- [x] Add sender/source pass/fail totals
- [ ] Add newly observed source detection
- [x] Add exportable domain reports
- [ ] Add actionable recommendations for common DMARC failure patterns
- [x] Add actionable recommendations for common DMARC failure patterns
## Future Milestones
- [ ] Production secret handling guide using 1Password injection