Merge pull request #120 from christianlouis/codex/notification-alert-rules

[codex] add notification alert rules
This commit is contained in:
Christian Krakau-Louis
2026-05-22 22:59:14 +02:00
committed by GitHub
9 changed files with 703 additions and 10 deletions
@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.setting import Setting
from app.services.alert_rules import evaluate_alert_rules, send_current_alerts
from app.services.notifications import send_notification
router = APIRouter()
@@ -127,6 +128,55 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.alert_new_sources_enabled",
"value": "true",
"description": "Alert when a new sending source appears in recent DMARC reports",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.alert_compliance_drop_enabled",
"value": "true",
"description": "Alert when DMARC compliance drops by the configured percentage points",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.alert_compliance_drop_points",
"value": "10",
"description": "Minimum compliance-rate drop, in percentage points, before alerting",
"value_type": "integer",
"category": "notifications",
},
{
"key": "notifications.alert_failure_threshold_enabled",
"value": "true",
"description": "Alert when DMARC failures exceed the configured daily threshold",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.alert_failure_threshold_count",
"value": "100",
"description": "Minimum failed message count in the last day before alerting",
"value_type": "integer",
"category": "notifications",
},
{
"key": "notifications.alert_missing_reports_enabled",
"value": "true",
"description": "Alert when a monitored domain has not received recent DMARC reports",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.alert_missing_reports_days",
"value": "2",
"description": "Number of days without DMARC reports before alerting",
"value_type": "integer",
"category": "notifications",
},
]
# Keys whose values should be redacted in GET responses (treated as secrets)
@@ -216,6 +266,19 @@ class NotificationTestResponse(BaseModel):
error: Optional[str] = None
class AlertRulesResponse(BaseModel):
"""Current alert-rule evaluation response."""
alerts: List[Dict[str, Any]]
class AlertNotificationResponse(BaseModel):
"""Alert-rule evaluation plus notification delivery status."""
alerts: List[Dict[str, Any]]
notification: Dict[str, Any]
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@@ -261,6 +324,33 @@ async def test_notification_settings(
return result.to_dict()
@router.get("/notifications/alerts", response_model=AlertRulesResponse)
async def evaluate_notification_alerts(
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> AlertRulesResponse:
"""Evaluate enabled notification alert rules against current DMARC data."""
_seed_defaults(db)
return {"alerts": evaluate_alert_rules(db)}
@router.post("/notifications/alerts/send", response_model=AlertNotificationResponse)
async def send_notification_alerts(
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> AlertNotificationResponse:
"""Evaluate current alert rules and send a notification summary when needed."""
_seed_defaults(db)
result = send_current_alerts(db)
notification = result["notification"]
if result["alerts"] and not notification.get("success"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result,
)
return result
@router.get("/{key:path}", response_model=SettingResponse)
async def get_setting(
key: str,
+256
View File
@@ -0,0 +1,256 @@
"""Alert rule evaluation for DMARC report data."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import case, func
from sqlalchemy.orm import Session
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
from app.models.setting import Setting
from app.services.notifications import NotificationResult, send_notification
def _truthy(value: Optional[str], default: bool = True) -> bool:
if value in (None, ""):
return default
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _int_setting(value: Optional[str], default: int) -> int:
try:
return int(value or default)
except (TypeError, ValueError):
return default
def _settings(db: Session) -> Dict[str, Optional[str]]:
rows = db.query(Setting).filter(Setting.category == "notifications").all()
return {row.key: row.value for row in rows}
def _days_ago_ts(days: int) -> int:
return int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp())
def _new_source_alerts(db: Session, window_days: int = 7) -> List[Dict[str, Any]]:
cutoff_ts = _days_ago_ts(window_days)
previous_sources = {
(row.domain, row.source_ip)
for row in (
db.query(Domain.name.label("domain"), ReportRecord.source_ip.label("source_ip"))
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
.filter(DMARCReport.begin_date < cutoff_ts)
.distinct()
.all()
)
}
current_sources = (
db.query(
Domain.name.label("domain"),
ReportRecord.source_ip.label("source_ip"),
func.sum(ReportRecord.count).label("message_count"),
)
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
.filter(DMARCReport.begin_date >= cutoff_ts)
.group_by(Domain.name, ReportRecord.source_ip)
.order_by(func.sum(ReportRecord.count).desc())
.all()
)
alerts = []
for row in current_sources:
if (row.domain, row.source_ip) in previous_sources:
continue
count = int(row.message_count or 0)
alerts.append(
{
"rule": "new_sender_source",
"severity": "warning",
"domain": row.domain,
"source_ip": row.source_ip,
"message_count": count,
"title": "New sending source",
"detail": f"{row.source_ip} first appeared for {row.domain} with {count} messages.",
}
)
return alerts
def _failure_threshold_alerts(
db: Session, threshold: int, window_days: int = 1
) -> List[Dict[str, Any]]:
cutoff_ts = _days_ago_ts(window_days)
rows = (
db.query(
Domain.name.label("domain"),
func.sum(ReportRecord.count).label("failed_messages"),
)
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
.filter(DMARCReport.begin_date >= cutoff_ts)
.filter(func.coalesce(ReportRecord.dkim, "fail") != "pass")
.filter(func.coalesce(ReportRecord.spf, "fail") != "pass")
.group_by(Domain.name)
.having(func.sum(ReportRecord.count) >= threshold)
.all()
)
return [
{
"rule": "dmarc_failures_above_threshold",
"severity": "error",
"domain": row.domain,
"failed_messages": int(row.failed_messages or 0),
"threshold": threshold,
"title": "DMARC failures above threshold",
"detail": (
f"{row.domain} had {int(row.failed_messages or 0)} DMARC failures "
f"in the last {window_days} day(s)."
),
}
for row in rows
]
def _missing_report_alerts(db: Session, missing_days: int) -> List[Dict[str, Any]]:
cutoff_ts = _days_ago_ts(missing_days)
latest_rows = (
db.query(
Domain.name.label("domain"),
func.max(DMARCReport.end_date).label("last_report_ts"),
)
.outerjoin(DMARCReport, DMARCReport.domain_id == Domain.id)
.group_by(Domain.id, Domain.name)
.all()
)
alerts = []
for row in latest_rows:
last_report_ts = int(row.last_report_ts or 0)
if last_report_ts >= cutoff_ts:
continue
alerts.append(
{
"rule": "missing_reports",
"severity": "warning",
"domain": row.domain,
"missing_days": missing_days,
"last_report_at": (
datetime.fromtimestamp(last_report_ts, tz=timezone.utc).isoformat()
if last_report_ts
else None
),
"title": "Missing DMARC reports",
"detail": f"{row.domain} has no DMARC report in the last {missing_days} day(s).",
}
)
return alerts
def _compliance_drop_alerts(
db: Session, drop_points: int, window_days: int = 2
) -> List[Dict[str, Any]]:
cutoff_ts = _days_ago_ts(window_days)
rows = (
db.query(
Domain.name.label("domain"),
DMARCReport.begin_date.label("begin_date"),
func.sum(ReportRecord.count).label("total"),
func.sum(
case(
(
(ReportRecord.dkim == "pass") | (ReportRecord.spf == "pass"),
ReportRecord.count,
),
else_=0,
)
).label("passed"),
)
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
.filter(DMARCReport.begin_date >= cutoff_ts)
.group_by(Domain.name, DMARCReport.begin_date)
.order_by(Domain.name, DMARCReport.begin_date)
.all()
)
by_domain: Dict[str, List[Dict[str, float]]] = {}
for row in rows:
total = int(row.total or 0)
passed = int(row.passed or 0)
rate = round((passed / total) * 100, 1) if total else 0.0
by_domain.setdefault(row.domain, []).append({"date": row.begin_date, "rate": rate})
alerts = []
for domain, points in by_domain.items():
if len(points) < 2:
continue
previous = points[-2]
current = points[-1]
drop = round(previous["rate"] - current["rate"], 1)
if drop < drop_points:
continue
alerts.append(
{
"rule": "compliance_drop",
"severity": "error" if drop >= 25 else "warning",
"domain": domain,
"previous_rate": previous["rate"],
"current_rate": current["rate"],
"drop": drop,
"threshold": drop_points,
"title": "Compliance dropped",
"detail": f"{domain} compliance fell by {drop} percentage points.",
}
)
return alerts
def evaluate_alert_rules(db: Session) -> List[Dict[str, Any]]:
"""Evaluate all enabled alert rules against persisted DMARC data."""
settings = _settings(db)
alerts: List[Dict[str, Any]] = []
if _truthy(settings.get("notifications.alert_new_sources_enabled")):
alerts.extend(_new_source_alerts(db))
if _truthy(settings.get("notifications.alert_failure_threshold_enabled")):
threshold = _int_setting(settings.get("notifications.alert_failure_threshold_count"), 100)
alerts.extend(_failure_threshold_alerts(db, threshold))
if _truthy(settings.get("notifications.alert_missing_reports_enabled")):
missing_days = _int_setting(settings.get("notifications.alert_missing_reports_days"), 2)
alerts.extend(_missing_report_alerts(db, missing_days))
if _truthy(settings.get("notifications.alert_compliance_drop_enabled")):
drop_points = _int_setting(settings.get("notifications.alert_compliance_drop_points"), 10)
alerts.extend(_compliance_drop_alerts(db, drop_points))
return alerts
def send_current_alerts(db: Session) -> Dict[str, Any]:
"""Evaluate current alert rules and send one summary notification when needed."""
alerts = evaluate_alert_rules(db)
if not alerts:
return {
"alerts": [],
"notification": NotificationResult(
success=True, skipped=True, message="No alerts."
).to_dict(),
}
lines = [f"{alert['title']}: {alert['detail']}" for alert in alerts[:10]]
if len(alerts) > 10:
lines.append(f"...and {len(alerts) - 10} more alert(s).")
result = send_notification(
db,
title=f"DMARQ alert summary: {len(alerts)} alert(s)",
body="\n".join(lines),
)
return {"alerts": alerts, "notification": result.to_dict()}
+141
View File
@@ -235,7 +235,100 @@
</div>
</template>
<div class="border-t border-border pt-4 space-y-4">
<div>
<h3 class="text-sm font-semibold">Alert Rules</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.alert_new_sources_enabled'] === 'true'"
@change="s['notifications.alert_new_sources_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">New sending sources</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.alert_missing_reports_enabled'] === 'true'"
@change="s['notifications.alert_missing_reports_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Missing reports</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.alert_failure_threshold_enabled'] === 'true'"
@change="s['notifications.alert_failure_threshold_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">High DMARC failures</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.alert_compliance_drop_enabled'] === 'true'"
@change="s['notifications.alert_compliance_drop_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Compliance drops</span>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Failure Threshold</span></label>
<input type="number" x-model.number="s['notifications.alert_failure_threshold_count']"
class="input input-bordered w-full" min="1" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Missing After Days</span></label>
<input type="number" x-model.number="s['notifications.alert_missing_reports_days']"
class="input input-bordered w-full" min="1" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Drop Points</span></label>
<input type="number" x-model.number="s['notifications.alert_compliance_drop_points']"
class="input input-bordered w-full" min="1" max="100" />
</div>
</div>
<template x-if="alertPreview !== null">
<div class="alert" :class="alertPreview > 0 ? 'alert-warning' : 'alert-success'">
<span x-text="alertPreview > 0 ? `${alertPreview} active alert${alertPreview === 1 ? '' : 's'} found.` : 'No active alerts found.'"></span>
</div>
</template>
</div>
<div class="flex flex-col sm:flex-row justify-end gap-2">
<button type="button" class="btn btn-outline btn-md" :disabled="saving || checkingAlerts" @click="checkAlerts()">
<template x-if="!checkingAlerts">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
</template>
<template x-if="checkingAlerts"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Check Alerts
</button>
<button type="button" class="btn btn-outline btn-md" :disabled="saving || sendingAlerts" @click="sendAlertSummary()">
<template x-if="!sendingAlerts">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
<path d="M10.268 21a2 2 0 0 0 3.464 0"></path>
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path>
</svg>
</template>
<template x-if="sendingAlerts"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Send Alerts Now
</button>
<button type="button" class="btn btn-outline btn-md" :disabled="saving || testingNotification" @click="sendTestNotification()">
<template x-if="!testingNotification">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
@@ -292,6 +385,9 @@ function settingsApp() {
flashMsg: '',
flashOk: true,
testingNotification: false,
checkingAlerts: false,
sendingAlerts: false,
alertPreview: null,
showCfToken: false,
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
@@ -369,6 +465,51 @@ function settingsApp() {
}
},
async checkAlerts() {
this.checkingAlerts = true;
try {
await this.saveCategory('notifications');
const res = await fetch('/api/v1/settings/notifications/alerts', {
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
this.showFlash('Alert check failed: ' + (data.detail || res.statusText), false);
} else {
this.alertPreview = (data.alerts || []).length;
this.showFlash(`${this.alertPreview} active alert${this.alertPreview === 1 ? '' : 's'} found.`, true);
}
} catch (err) {
this.showFlash('Error checking alerts: ' + err.message, false);
} finally {
this.checkingAlerts = false;
}
},
async sendAlertSummary() {
this.sendingAlerts = true;
try {
await this.saveCategory('notifications');
const res = await fetch('/api/v1/settings/notifications/alerts/send', {
method: 'POST',
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail || {};
const notification = detail.notification || {};
this.showFlash('Alert send failed: ' + (notification.message || data.detail || res.statusText), false);
} else {
this.alertPreview = (data.alerts || []).length;
this.showFlash(data.notification.skipped ? 'No active alerts to send.' : 'Alert summary sent.', true);
}
} catch (err) {
this.showFlash('Error sending alerts: ' + err.message, false);
} finally {
this.sendingAlerts = false;
}
},
showFlash(msg, ok) {
this.flashMsg = msg;
this.flashOk = ok;
+197
View File
@@ -2,10 +2,61 @@
Tests for the Settings model and /api/v1/settings endpoints.
"""
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
from app.models.setting import Setting
from app.services.notifications import NotificationResult
def _timestamp_days_ago(days: int) -> int:
return int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp())
def _add_domain(db_session: Session, name: str) -> Domain:
domain = Domain(name=name)
db_session.add(domain)
db_session.flush()
return domain
def _add_report_record(
db_session: Session,
domain: Domain,
*,
report_id: str,
days_ago: int,
source_ip: str,
count: int,
dkim: str = "pass",
spf: str = "pass",
) -> None:
begin_date = _timestamp_days_ago(days_ago)
report = DMARCReport(
domain_id=domain.id,
report_id=report_id,
org_name="receiver.example",
begin_date=begin_date,
end_date=begin_date + 3600,
policy="none",
)
db_session.add(report)
db_session.flush()
db_session.add(
ReportRecord(
report_id=report.id,
source_ip=source_ip,
count=count,
disposition="none",
dkim=dkim,
spf=spf,
header_from=domain.name,
)
)
class TestSettingModel:
@@ -51,6 +102,10 @@ class TestSettingsAPI:
assert "cloudflare.api_token" in keys
assert "notifications.apprise_enabled" in keys
assert "notifications.apprise_urls" in keys
assert "notifications.alert_new_sources_enabled" in keys
assert "notifications.alert_compliance_drop_points" in keys
assert "notifications.alert_failure_threshold_count" in keys
assert "notifications.alert_missing_reports_days" in keys
def test_list_settings_filter_by_category(self, authed_client: TestClient):
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
@@ -213,3 +268,145 @@ class TestSettingsAPI:
detail = res.json()["detail"]
assert detail["success"] is False
assert detail["message"] == "No notification targets are configured."
def test_notification_alert_rules_detect_new_source_and_failures(
self,
authed_client: TestClient,
db_session: Session,
):
"""GET /settings/notifications/alerts evaluates source and failure alerts."""
domain = _add_domain(db_session, "alerts.example")
_add_report_record(
db_session,
domain,
report_id="alerts-old-source",
days_ago=10,
source_ip="203.0.113.10",
count=12,
)
_add_report_record(
db_session,
domain,
report_id="alerts-new-source",
days_ago=0,
source_ip="203.0.113.20",
count=150,
dkim="fail",
spf="fail",
)
db_session.commit()
res = authed_client.get("/api/v1/settings/notifications/alerts")
assert res.status_code == 200
alerts = res.json()["alerts"]
rules = {alert["rule"] for alert in alerts}
assert "new_sender_source" in rules
assert "dmarc_failures_above_threshold" in rules
new_source = next(alert for alert in alerts if alert["rule"] == "new_sender_source")
assert new_source["source_ip"] == "203.0.113.20"
def test_notification_alert_rules_detect_missing_reports(
self,
authed_client: TestClient,
db_session: Session,
):
"""Alert rules flag active monitored domains without recent reports."""
_add_domain(db_session, "missing.example")
db_session.commit()
res = authed_client.get("/api/v1/settings/notifications/alerts")
assert res.status_code == 200
alerts = res.json()["alerts"]
assert any(
alert["rule"] == "missing_reports" and alert["domain"] == "missing.example"
for alert in alerts
)
def test_notification_alert_rules_detect_compliance_drop(
self,
authed_client: TestClient,
db_session: Session,
):
"""Alert rules compare recent compliance rates and flag large drops."""
domain = _add_domain(db_session, "drop.example")
_add_report_record(
db_session,
domain,
report_id="drop-known-source",
days_ago=10,
source_ip="203.0.113.30",
count=3,
)
_add_report_record(
db_session,
domain,
report_id="drop-passing-day",
days_ago=1,
source_ip="203.0.113.30",
count=10,
)
_add_report_record(
db_session,
domain,
report_id="drop-failing-day",
days_ago=0,
source_ip="203.0.113.30",
count=10,
dkim="fail",
spf="fail",
)
db_session.commit()
res = authed_client.get("/api/v1/settings/notifications/alerts")
assert res.status_code == 200
alerts = res.json()["alerts"]
compliance_alert = next(alert for alert in alerts if alert["rule"] == "compliance_drop")
assert compliance_alert["domain"] == "drop.example"
assert compliance_alert["previous_rate"] == 100.0
assert compliance_alert["current_rate"] == 0.0
def test_notification_alert_send_uses_alert_summary(
self,
authed_client: TestClient,
db_session: Session,
monkeypatch,
):
"""POST /settings/notifications/alerts/send sends one alert summary."""
sent_messages = []
def fake_send_notification(
db, *, title, body, force=False
): # pylint: disable=unused-argument
sent_messages.append({"title": title, "body": body})
return NotificationResult(
success=True,
message="Notification sent.",
configured_targets=1,
)
monkeypatch.setattr("app.services.alert_rules.send_notification", fake_send_notification)
domain = _add_domain(db_session, "send.example")
_add_report_record(
db_session,
domain,
report_id="send-failing-day",
days_ago=0,
source_ip="203.0.113.40",
count=200,
dkim="fail",
spf="fail",
)
db_session.commit()
res = authed_client.post("/api/v1/settings/notifications/alerts/send")
assert res.status_code == 200
data = res.json()
assert data["notification"]["success"] is True
assert data["alerts"]
assert sent_messages[0]["title"].startswith("DMARQ alert summary")
assert "DMARC failures above threshold" in sent_messages[0]["body"]
+7
View File
@@ -85,6 +85,13 @@ from API responses.
|---------|-------------|---------|---------|
| `notifications.apprise_enabled` | Enable Apprise notification delivery | `false` | `true` |
| `notifications.apprise_urls` | Newline-separated Apprise target URLs | - | `mailto://user:pass@example.com` |
| `notifications.alert_new_sources_enabled` | Alert on newly observed sending sources | `true` | `true` |
| `notifications.alert_compliance_drop_enabled` | Alert on recent compliance-rate drops | `true` | `true` |
| `notifications.alert_compliance_drop_points` | Minimum compliance-rate drop in percentage points | `10` | `15` |
| `notifications.alert_failure_threshold_enabled` | Alert on high recent DMARC failure volume | `true` | `true` |
| `notifications.alert_failure_threshold_count` | Failed messages in the last day before alerting | `100` | `250` |
| `notifications.alert_missing_reports_enabled` | Alert when a monitored domain stops receiving reports | `true` | `true` |
| `notifications.alert_missing_reports_days` | Days without reports before alerting | `2` | `3` |
### Cloudflare Integration
+1 -1
View File
@@ -83,7 +83,7 @@ Follow-up:
## Later Milestones
- Notifications and alert rules. Apprise delivery and test notifications are in place; alert rules, summaries, and alert history remain.
- Notifications and alert rules. Apprise delivery, test notifications, and current alert-rule evaluation are in place; scheduled summaries and alert history remain.
- DNS health and Cloudflare read-only inspection.
- Guided setup and operator health screens.
- Forensic/RUF report support.
+2 -1
View File
@@ -114,9 +114,10 @@ Goal: notify administrators when action is needed.
Delivered:
- Apprise notification integration for newline-separated notification target URLs.
- Notification settings UI can save Apprise targets, keeps target URLs redacted after save, and can send a test notification.
- Alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports.
- Notification settings UI can evaluate active alerts and send the current alert summary on demand.
Planned:
- Alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports.
- Daily/weekly summary notifications.
- Alert history.
+1 -1
View File
@@ -165,7 +165,7 @@ Status: Complete for the delivered reporting milestone. Alert-specific dashboard
- [x] Add backup/restore guidance for database deployments
- [x] Add release checklist covering migrations, tests, and smoke checks
- [x] Add Apprise notification delivery and test notification support
- [ ] Add alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports
- [x] Add alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports
- [ ] Add daily and weekly summary notifications
- [ ] Add alert history
- [ ] DNS health guidance and Cloudflare read-only inspection
+8 -7
View File
@@ -44,14 +44,15 @@ settings API or page reloads.
### Alert Thresholds
Set thresholds for when alerts are triggered:
Set alert rules and thresholds in **Settings** > **Notifications**:
1. Navigate to **Settings** > **Notifications** > **Thresholds**
2. Configure thresholds for:
- **Compliance Rate Drop**: Alert when compliance falls below a threshold
- **New Sending Sources**: Alert when new IPs/servers send email as your domain
- **Authentication Failures**: Alert when failures exceed a certain number
- **Report Processing Issues**: Alert on report processing errors
- **New Sending Sources**: newly observed IPs or servers sending as a monitored domain
- **Compliance Drops**: recent compliance-rate drops beyond the configured point threshold
- **High DMARC Failures**: failed messages over the daily threshold
- **Missing Reports**: monitored domains without reports for the configured number of days
Use **Check Alerts** to preview the current alert count, or **Send Alerts Now** to
send the current alert summary through the configured Apprise targets.
### Integration Notifications