feat: add scheduled summary notifications

This commit is contained in:
Christian Krakau-Louis
2026-05-22 23:06:21 +02:00
parent 2fc7c40189
commit f4ff1f151d
10 changed files with 724 additions and 3 deletions
@@ -23,6 +23,7 @@ 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
from app.services.summary_notifications import build_summary, send_summary_notification
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -177,6 +178,48 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [
"value_type": "integer",
"category": "notifications",
},
{
"key": "notifications.summary_daily_enabled",
"value": "false",
"description": "Send one daily DMARC activity summary notification",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.summary_weekly_enabled",
"value": "false",
"description": "Send one weekly DMARC activity summary notification",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.summary_send_hour_utc",
"value": "8",
"description": "UTC hour when scheduled summary notifications can be sent",
"value_type": "integer",
"category": "notifications",
},
{
"key": "notifications.summary_weekday_utc",
"value": "0",
"description": "UTC weekday for weekly summaries, where 0 is Monday",
"value_type": "integer",
"category": "notifications",
},
{
"key": "notifications.summary_daily_last_sent_date",
"value": "",
"description": "Internal date marker for the last sent daily summary",
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.summary_weekly_last_sent_week",
"value": "",
"description": "Internal ISO week marker for the last sent weekly summary",
"value_type": "string",
"category": "notifications",
},
]
# Keys whose values should be redacted in GET responses (treated as secrets)
@@ -279,6 +322,19 @@ class AlertNotificationResponse(BaseModel):
notification: Dict[str, Any]
class SummaryResponse(BaseModel):
"""Current DMARC summary notification preview."""
summary: Dict[str, Any]
class SummaryNotificationResponse(BaseModel):
"""DMARC summary plus notification delivery status."""
summary: Dict[str, Any]
notification: Dict[str, Any]
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@@ -351,6 +407,48 @@ async def send_notification_alerts(
return result
@router.get("/notifications/summary", response_model=SummaryResponse)
async def preview_notification_summary(
period: str = "daily",
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> SummaryResponse:
"""Preview a daily or weekly DMARC summary notification."""
_seed_defaults(db)
try:
summary = build_summary(db, period)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
return {"summary": summary}
@router.post("/notifications/summary/send", response_model=SummaryNotificationResponse)
async def send_notification_summary(
period: str = "daily",
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> SummaryNotificationResponse:
"""Send a daily or weekly DMARC summary notification immediately."""
_seed_defaults(db)
try:
result = send_summary_notification(db, period)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
notification = result["notification"]
if 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,
+21
View File
@@ -27,6 +27,7 @@ from app.services.imap_client import IMAPClient
from app.services.import_history import record_import_attempt
from app.services.report_persistence import hydrate_report_store_from_db
from app.services.report_store import ReportStore
from app.services.summary_notifications import send_due_scheduled_summaries
# Set up logging
logger = logging.getLogger(__name__)
@@ -180,6 +181,25 @@ def _poll_all_enabled_sources() -> list[MailSource]:
return enabled_sources
def _send_due_summary_notifications() -> None:
"""Send scheduled summary notifications when their configured cadence is due."""
db = SessionLocal()
try:
results = send_due_scheduled_summaries(db)
for period, result in results.items():
notification = result.get("notification", {})
if notification.get("success"):
logger.info("Sent %s DMARC summary notification", period)
else:
logger.warning(
"%s DMARC summary notification was not sent: %s",
period.capitalize(),
notification.get("message", "Unknown error"),
)
finally:
db.close()
def _next_sleep_seconds(
min_sleep: int = 60, enabled_sources: list[MailSource] | None = None
) -> int:
@@ -206,6 +226,7 @@ async def scheduled_imap_polling():
logger.info("Starting scheduled IMAP polling for DMARC reports")
try:
enabled_sources = _poll_all_enabled_sources()
_send_due_summary_notifications()
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Error in IMAP polling task: %s", str(e))
enabled_sources = None
@@ -0,0 +1,299 @@
"""Daily and weekly DMARC summary notification helpers."""
from __future__ import annotations
import logging
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.alert_rules import evaluate_alert_rules
from app.services.notifications import send_notification
logger = logging.getLogger(__name__)
SUMMARY_PERIODS = {
"daily": 1,
"weekly": 7,
}
def _truthy(value: Optional[str], default: bool = False) -> 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 _set_setting(db: Session, key: str, value: str) -> None:
row = db.query(Setting).filter(Setting.key == key).first()
if row is None:
row = Setting(
key=key,
value=value,
category="notifications",
value_type="string",
)
db.add(row)
else:
row.value = value
db.commit()
def _period_days(period: str) -> int:
if period not in SUMMARY_PERIODS:
raise ValueError("Summary period must be daily or weekly.")
return SUMMARY_PERIODS[period]
def _cutoff_ts(period: str, now: Optional[datetime] = None) -> int:
reference = now or datetime.now(timezone.utc)
return int((reference - timedelta(days=_period_days(period))).timestamp())
def _recent_records_query(db: Session, cutoff_ts: int):
return (
db.query(
Domain.name.label("domain"),
ReportRecord.source_ip.label("source_ip"),
ReportRecord.count.label("count"),
ReportRecord.dkim.label("dkim"),
ReportRecord.spf.label("spf"),
)
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
.filter(DMARCReport.begin_date >= cutoff_ts)
)
def _aggregate_totals(db: Session, cutoff_ts: int) -> Dict[str, int]:
row = (
db.query(
func.coalesce(func.sum(ReportRecord.count), 0).label("total_messages"),
func.coalesce(
func.sum(
case(
(
(ReportRecord.dkim == "pass") | (ReportRecord.spf == "pass"),
ReportRecord.count,
),
else_=0,
)
),
0,
).label("compliant_messages"),
func.coalesce(
func.sum(
case(
(
(func.coalesce(ReportRecord.dkim, "fail") != "pass")
& (func.coalesce(ReportRecord.spf, "fail") != "pass"),
ReportRecord.count,
),
else_=0,
)
),
0,
).label("failed_messages"),
)
.join(DMARCReport, DMARCReport.id == ReportRecord.report_id)
.filter(DMARCReport.begin_date >= cutoff_ts)
.first()
)
if row is None:
return {"total_messages": 0, "compliant_messages": 0, "failed_messages": 0}
return {
"total_messages": int(row.total_messages or 0),
"compliant_messages": int(row.compliant_messages or 0),
"failed_messages": int(row.failed_messages or 0),
}
def _top_domains(db: Session, cutoff_ts: int, limit: int = 5) -> List[Dict[str, Any]]:
rows = (
_recent_records_query(db, cutoff_ts)
.with_entities(
Domain.name.label("domain"),
func.sum(ReportRecord.count).label("message_count"),
)
.group_by(Domain.name)
.order_by(func.sum(ReportRecord.count).desc())
.limit(limit)
.all()
)
return [
{
"domain": row.domain,
"message_count": int(row.message_count or 0),
}
for row in rows
]
def _top_sources(db: Session, cutoff_ts: int, limit: int = 5) -> List[Dict[str, Any]]:
rows = (
_recent_records_query(db, cutoff_ts)
.with_entities(
ReportRecord.source_ip.label("source_ip"),
func.sum(ReportRecord.count).label("message_count"),
)
.group_by(ReportRecord.source_ip)
.order_by(func.sum(ReportRecord.count).desc())
.limit(limit)
.all()
)
return [
{
"source_ip": row.source_ip,
"message_count": int(row.message_count or 0),
}
for row in rows
]
def build_summary(
db: Session, period: str = "daily", now: Optional[datetime] = None
) -> Dict[str, Any]:
"""Build a daily or weekly DMARC activity summary."""
cutoff = _cutoff_ts(period, now)
totals = _aggregate_totals(db, cutoff)
total_messages = totals["total_messages"]
compliance_rate = (
round((totals["compliant_messages"] / total_messages) * 100, 1) if total_messages else 0.0
)
reports_processed = (
db.query(func.count(DMARCReport.id)).filter(DMARCReport.begin_date >= cutoff).scalar() or 0
)
return {
"period": period,
"period_days": _period_days(period),
"generated_at": (now or datetime.now(timezone.utc)).isoformat(),
"total_domains": int(db.query(func.count(Domain.id)).scalar() or 0),
"reports_processed": int(reports_processed),
"total_messages": total_messages,
"compliant_messages": totals["compliant_messages"],
"failed_messages": totals["failed_messages"],
"compliance_rate": compliance_rate,
"top_domains": _top_domains(db, cutoff),
"top_sources": _top_sources(db, cutoff),
"alerts": evaluate_alert_rules(db),
}
def format_summary_body(summary: Dict[str, Any]) -> str:
"""Format a DMARC summary as a concise notification body."""
period_label = summary["period"].capitalize()
lines = [
f"{period_label} DMARC summary",
f"Reports processed: {summary['reports_processed']}",
f"Messages observed: {summary['total_messages']}",
f"Compliance rate: {summary['compliance_rate']}%",
f"DMARC failures: {summary['failed_messages']}",
f"Active alerts: {len(summary['alerts'])}",
]
if summary["top_domains"]:
lines.append("")
lines.append("Top domains:")
lines.extend(
f"- {item['domain']}: {item['message_count']} messages"
for item in summary["top_domains"][:5]
)
if summary["top_sources"]:
lines.append("")
lines.append("Top sources:")
lines.extend(
f"- {item['source_ip']}: {item['message_count']} messages"
for item in summary["top_sources"][:5]
)
if summary["alerts"]:
lines.append("")
lines.append("Alerts:")
lines.extend(f"- {alert['title']}: {alert['detail']}" for alert in summary["alerts"][:5])
if len(summary["alerts"]) > 5:
lines.append(f"...and {len(summary['alerts']) - 5} more alert(s).")
return "\n".join(lines)
def send_summary_notification(db: Session, period: str = "daily") -> Dict[str, Any]:
"""Build and send a daily or weekly summary notification."""
summary = build_summary(db, period)
title = (
f"DMARQ {period} summary: "
f"{summary['total_messages']} message(s), {len(summary['alerts'])} alert(s)"
)
result = send_notification(
db,
title=title,
body=format_summary_body(summary),
)
return {"summary": summary, "notification": result.to_dict()}
def _daily_due(settings: Dict[str, Optional[str]], now: datetime) -> bool:
last_sent = settings.get("notifications.summary_daily_last_sent_date")
return last_sent != now.date().isoformat()
def _weekly_due(settings: Dict[str, Optional[str]], now: datetime) -> bool:
last_sent = settings.get("notifications.summary_weekly_last_sent_week")
year, week, _ = now.isocalendar()
return last_sent != f"{year}-W{week:02d}"
def send_due_scheduled_summaries(
db: Session,
now: Optional[datetime] = None,
) -> Dict[str, Dict[str, Any]]:
"""Send enabled daily and weekly summaries when their schedule is due."""
settings = _settings(db)
current_time = now or datetime.now(timezone.utc)
send_hour = max(
0, min(23, _int_setting(settings.get("notifications.summary_send_hour_utc"), 8))
)
if current_time.hour < send_hour:
return {}
results: Dict[str, Dict[str, Any]] = {}
if _truthy(settings.get("notifications.summary_daily_enabled")) and _daily_due(
settings, current_time
):
results["daily"] = send_summary_notification(db, "daily")
if results["daily"]["notification"].get("success"):
_set_setting(
db,
"notifications.summary_daily_last_sent_date",
current_time.date().isoformat(),
)
weekday = max(0, min(6, _int_setting(settings.get("notifications.summary_weekday_utc"), 0)))
if (
_truthy(settings.get("notifications.summary_weekly_enabled"))
and current_time.weekday() == weekday
and _weekly_due(settings, current_time)
):
results["weekly"] = send_summary_notification(db, "weekly")
if results["weekly"]["notification"].get("success"):
year, week, _ = current_time.isocalendar()
_set_setting(db, "notifications.summary_weekly_last_sent_week", f"{year}-W{week:02d}")
return results
+134
View File
@@ -307,7 +307,92 @@
</template>
</div>
<div class="border-t border-border pt-4 space-y-4">
<div>
<h3 class="text-sm font-semibold">Summary Notifications</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.summary_daily_enabled'] === 'true'"
@change="s['notifications.summary_daily_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Daily summary</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.summary_weekly_enabled'] === 'true'"
@change="s['notifications.summary_weekly_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Weekly summary</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">Send Hour (UTC)</span></label>
<input type="number" x-model.number="s['notifications.summary_send_hour_utc']"
class="input input-bordered w-full" min="0" max="23" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Weekly Day</span></label>
<select x-model="s['notifications.summary_weekday_utc']" class="input input-bordered w-full">
<option value="0">Monday</option>
<option value="1">Tuesday</option>
<option value="2">Wednesday</option>
<option value="3">Thursday</option>
<option value="4">Friday</option>
<option value="5">Saturday</option>
<option value="6">Sunday</option>
</select>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Preview Period</span></label>
<select x-model="summaryPeriod" class="input input-bordered w-full">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
</div>
<template x-if="summaryPreview">
<div class="alert alert-info">
<span x-text="`${summaryPreview.total_messages} messages, ${summaryPreview.reports_processed} reports, ${summaryPreview.alerts.length} active alerts.`"></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 || previewingSummary" @click="previewSummary()">
<template x-if="!previewingSummary">
<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="M3 3v18h18"></path>
<path d="M18 17V9"></path>
<path d="M13 17V5"></path>
<path d="M8 17v-3"></path>
</svg>
</template>
<template x-if="previewingSummary"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Preview Summary
</button>
<button type="button" class="btn btn-outline btn-md" :disabled="saving || sendingSummary" @click="sendSummaryNow()">
<template x-if="!sendingSummary">
<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="M3 3v18h18"></path>
<path d="m19 9-5 5-4-4-3 3"></path>
</svg>
</template>
<template x-if="sendingSummary"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Send Summary Now
</button>
<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"
@@ -388,6 +473,10 @@ function settingsApp() {
checkingAlerts: false,
sendingAlerts: false,
alertPreview: null,
previewingSummary: false,
sendingSummary: false,
summaryPeriod: 'daily',
summaryPreview: null,
showCfToken: false,
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
@@ -510,6 +599,51 @@ function settingsApp() {
}
},
async previewSummary() {
this.previewingSummary = true;
try {
await this.saveCategory('notifications');
const res = await fetch(`/api/v1/settings/notifications/summary?period=${this.summaryPeriod}`, {
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
this.showFlash('Summary preview failed: ' + (data.detail || res.statusText), false);
} else {
this.summaryPreview = data.summary;
this.showFlash('Summary preview updated.', true);
}
} catch (err) {
this.showFlash('Error previewing summary: ' + err.message, false);
} finally {
this.previewingSummary = false;
}
},
async sendSummaryNow() {
this.sendingSummary = true;
try {
await this.saveCategory('notifications');
const res = await fetch(`/api/v1/settings/notifications/summary/send?period=${this.summaryPeriod}`, {
method: 'POST',
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail || {};
const notification = detail.notification || {};
this.showFlash('Summary send failed: ' + (notification.message || data.detail || res.statusText), false);
} else {
this.summaryPreview = data.summary;
this.showFlash('Summary notification sent.', true);
}
} catch (err) {
this.showFlash('Error sending summary: ' + err.message, false);
} finally {
this.sendingSummary = false;
}
},
showFlash(msg, ok) {
this.flashMsg = msg;
this.flashOk = ok;
+156
View File
@@ -11,6 +11,7 @@ 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
from app.services.summary_notifications import send_due_scheduled_summaries
def _timestamp_days_ago(days: int) -> int:
@@ -106,6 +107,10 @@ class TestSettingsAPI:
assert "notifications.alert_compliance_drop_points" in keys
assert "notifications.alert_failure_threshold_count" in keys
assert "notifications.alert_missing_reports_days" in keys
assert "notifications.summary_daily_enabled" in keys
assert "notifications.summary_weekly_enabled" in keys
assert "notifications.summary_send_hour_utc" in keys
assert "notifications.summary_weekday_utc" in keys
def test_list_settings_filter_by_category(self, authed_client: TestClient):
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
@@ -410,3 +415,154 @@ class TestSettingsAPI:
assert data["alerts"]
assert sent_messages[0]["title"].startswith("DMARQ alert summary")
assert "DMARC failures above threshold" in sent_messages[0]["body"]
def test_notification_summary_preview_returns_recent_activity(
self,
authed_client: TestClient,
db_session: Session,
):
"""GET /settings/notifications/summary returns a daily summary preview."""
domain = _add_domain(db_session, "summary.example")
_add_report_record(
db_session,
domain,
report_id="summary-recent",
days_ago=0,
source_ip="203.0.113.50",
count=25,
)
_add_report_record(
db_session,
domain,
report_id="summary-old",
days_ago=3,
source_ip="203.0.113.51",
count=99,
)
db_session.commit()
res = authed_client.get("/api/v1/settings/notifications/summary?period=daily")
assert res.status_code == 200
summary = res.json()["summary"]
assert summary["period"] == "daily"
assert summary["total_messages"] == 25
assert summary["reports_processed"] == 1
assert summary["top_domains"][0]["domain"] == "summary.example"
def test_notification_summary_send_uses_apprise_summary(
self,
authed_client: TestClient,
db_session: Session,
monkeypatch,
):
"""POST /settings/notifications/summary/send sends the selected 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.summary_notifications.send_notification",
fake_send_notification,
)
domain = _add_domain(db_session, "weekly.example")
_add_report_record(
db_session,
domain,
report_id="weekly-recent",
days_ago=2,
source_ip="203.0.113.60",
count=40,
)
db_session.commit()
res = authed_client.post("/api/v1/settings/notifications/summary/send?period=weekly")
assert res.status_code == 200
data = res.json()
assert data["notification"]["success"] is True
assert data["summary"]["period"] == "weekly"
assert sent_messages[0]["title"].startswith("DMARQ weekly summary")
assert "Weekly DMARC summary" in sent_messages[0]["body"]
def test_due_scheduled_summaries_send_once_per_period(
self,
db_session: Session,
monkeypatch,
):
"""Scheduled summaries respect enabled settings and last-sent markers."""
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.summary_notifications.send_notification",
fake_send_notification,
)
db_session.add_all(
[
Setting(
key="notifications.summary_daily_enabled",
value="true",
category="notifications",
),
Setting(
key="notifications.summary_weekly_enabled",
value="true",
category="notifications",
),
Setting(
key="notifications.summary_send_hour_utc",
value="8",
category="notifications",
),
Setting(
key="notifications.summary_weekday_utc",
value="0",
category="notifications",
),
]
)
domain = _add_domain(db_session, "scheduled.example")
_add_report_record(
db_session,
domain,
report_id="scheduled-recent",
days_ago=0,
source_ip="203.0.113.70",
count=12,
)
db_session.commit()
now = datetime(2026, 5, 18, 8, 30, tzinfo=timezone.utc)
first = send_due_scheduled_summaries(db_session, now=now)
second = send_due_scheduled_summaries(db_session, now=now)
assert set(first) == {"daily", "weekly"}
assert second == {}
assert len(sent_messages) == 2
assert (
db_session.query(Setting)
.filter(Setting.key == "notifications.summary_daily_last_sent_date")
.first()
.value
== "2026-05-18"
)
+4
View File
@@ -92,6 +92,10 @@ from API responses.
| `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` |
| `notifications.summary_daily_enabled` | Send one daily DMARC activity summary | `false` | `true` |
| `notifications.summary_weekly_enabled` | Send one weekly DMARC activity summary | `false` | `true` |
| `notifications.summary_send_hour_utc` | UTC hour for scheduled summaries | `8` | `7` |
| `notifications.summary_weekday_utc` | UTC weekday for weekly summaries, where 0 is Monday | `0` | `4` |
### Cloudflare Integration
+1 -1
View File
@@ -83,7 +83,7 @@ Follow-up:
## Later Milestones
- Notifications and alert rules. Apprise delivery, test notifications, and current alert-rule evaluation are in place; scheduled summaries and alert history remain.
- Notifications and alert rules. Apprise delivery, test notifications, alert-rule evaluation, and scheduled daily/weekly summaries are in place; alert history remains.
- DNS health and Cloudflare read-only inspection.
- Guided setup and operator health screens.
- Forensic/RUF report support.
+1 -1
View File
@@ -116,9 +116,9 @@ Delivered:
- 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.
- Daily and weekly DMARC summary notifications, including scheduled delivery and manual preview/send controls.
Planned:
- Daily/weekly summary notifications.
- Alert history.
Exit criteria:
+1 -1
View File
@@ -166,7 +166,7 @@ Status: Complete for the delivered reporting milestone. Alert-specific dashboard
- [x] Add release checklist covering migrations, tests, and smoke checks
- [x] Add Apprise notification delivery and test notification support
- [x] Add alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports
- [ ] Add daily and weekly summary notifications
- [x] Add daily and weekly summary notifications
- [ ] Add alert history
- [ ] DNS health guidance and Cloudflare read-only inspection
- [ ] Guided setup and operator health pages
+9
View File
@@ -54,6 +54,15 @@ Set alert rules and thresholds in **Settings** > **Notifications**:
Use **Check Alerts** to preview the current alert count, or **Send Alerts Now** to
send the current alert summary through the configured Apprise targets.
### Summary Notifications
Configure daily or weekly summaries in **Settings** > **Notifications**:
- **Daily Summary**: sends one summary after the configured UTC hour each day
- **Weekly Summary**: sends one summary on the configured UTC weekday
- **Preview Summary**: shows message volume, report count, and active alert count
- **Send Summary Now**: sends the selected daily or weekly summary immediately
### Integration Notifications
Apprise supports email, Slack, Teams, Discord, generic webhooks, and many other