feat: add scheduled summary notifications
This commit is contained in:
@@ -23,6 +23,7 @@ from app.core.security import require_admin_auth
|
|||||||
from app.models.setting import Setting
|
from app.models.setting import Setting
|
||||||
from app.services.alert_rules import evaluate_alert_rules, send_current_alerts
|
from app.services.alert_rules import evaluate_alert_rules, send_current_alerts
|
||||||
from app.services.notifications import send_notification
|
from app.services.notifications import send_notification
|
||||||
|
from app.services.summary_notifications import build_summary, send_summary_notification
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -177,6 +178,48 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [
|
|||||||
"value_type": "integer",
|
"value_type": "integer",
|
||||||
"category": "notifications",
|
"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)
|
# Keys whose values should be redacted in GET responses (treated as secrets)
|
||||||
@@ -279,6 +322,19 @@ class AlertNotificationResponse(BaseModel):
|
|||||||
notification: Dict[str, Any]
|
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
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -351,6 +407,48 @@ async def send_notification_alerts(
|
|||||||
return result
|
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)
|
@router.get("/{key:path}", response_model=SettingResponse)
|
||||||
async def get_setting(
|
async def get_setting(
|
||||||
key: str,
|
key: str,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.services.imap_client import IMAPClient
|
|||||||
from app.services.import_history import record_import_attempt
|
from app.services.import_history import record_import_attempt
|
||||||
from app.services.report_persistence import hydrate_report_store_from_db
|
from app.services.report_persistence import hydrate_report_store_from_db
|
||||||
from app.services.report_store import ReportStore
|
from app.services.report_store import ReportStore
|
||||||
|
from app.services.summary_notifications import send_due_scheduled_summaries
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -180,6 +181,25 @@ def _poll_all_enabled_sources() -> list[MailSource]:
|
|||||||
return enabled_sources
|
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(
|
def _next_sleep_seconds(
|
||||||
min_sleep: int = 60, enabled_sources: list[MailSource] | None = None
|
min_sleep: int = 60, enabled_sources: list[MailSource] | None = None
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -206,6 +226,7 @@ async def scheduled_imap_polling():
|
|||||||
logger.info("Starting scheduled IMAP polling for DMARC reports")
|
logger.info("Starting scheduled IMAP polling for DMARC reports")
|
||||||
try:
|
try:
|
||||||
enabled_sources = _poll_all_enabled_sources()
|
enabled_sources = _poll_all_enabled_sources()
|
||||||
|
_send_due_summary_notifications()
|
||||||
except Exception as e: # pylint: disable=broad-exception-caught
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
logger.error("Error in IMAP polling task: %s", str(e))
|
logger.error("Error in IMAP polling task: %s", str(e))
|
||||||
enabled_sources = None
|
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
|
||||||
@@ -307,7 +307,92 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</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">
|
<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()">
|
<button type="button" class="btn btn-outline btn-md" :disabled="saving || checkingAlerts" @click="checkAlerts()">
|
||||||
<template x-if="!checkingAlerts">
|
<template x-if="!checkingAlerts">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
<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,
|
checkingAlerts: false,
|
||||||
sendingAlerts: false,
|
sendingAlerts: false,
|
||||||
alertPreview: null,
|
alertPreview: null,
|
||||||
|
previewingSummary: false,
|
||||||
|
sendingSummary: false,
|
||||||
|
summaryPeriod: 'daily',
|
||||||
|
summaryPreview: null,
|
||||||
showCfToken: false,
|
showCfToken: false,
|
||||||
|
|
||||||
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
|
// 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) {
|
showFlash(msg, ok) {
|
||||||
this.flashMsg = msg;
|
this.flashMsg = msg;
|
||||||
this.flashOk = ok;
|
this.flashOk = ok;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.models.domain import Domain
|
|||||||
from app.models.report import DMARCReport, ReportRecord
|
from app.models.report import DMARCReport, ReportRecord
|
||||||
from app.models.setting import Setting
|
from app.models.setting import Setting
|
||||||
from app.services.notifications import NotificationResult
|
from app.services.notifications import NotificationResult
|
||||||
|
from app.services.summary_notifications import send_due_scheduled_summaries
|
||||||
|
|
||||||
|
|
||||||
def _timestamp_days_ago(days: int) -> int:
|
def _timestamp_days_ago(days: int) -> int:
|
||||||
@@ -106,6 +107,10 @@ class TestSettingsAPI:
|
|||||||
assert "notifications.alert_compliance_drop_points" in keys
|
assert "notifications.alert_compliance_drop_points" in keys
|
||||||
assert "notifications.alert_failure_threshold_count" in keys
|
assert "notifications.alert_failure_threshold_count" in keys
|
||||||
assert "notifications.alert_missing_reports_days" 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):
|
def test_list_settings_filter_by_category(self, authed_client: TestClient):
|
||||||
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
|
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
|
||||||
@@ -410,3 +415,154 @@ class TestSettingsAPI:
|
|||||||
assert data["alerts"]
|
assert data["alerts"]
|
||||||
assert sent_messages[0]["title"].startswith("DMARQ alert summary")
|
assert sent_messages[0]["title"].startswith("DMARQ alert summary")
|
||||||
assert "DMARC failures above threshold" in sent_messages[0]["body"]
|
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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ from API responses.
|
|||||||
| `notifications.alert_failure_threshold_count` | Failed messages in the last day before alerting | `100` | `250` |
|
| `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_enabled` | Alert when a monitored domain stops receiving reports | `true` | `true` |
|
||||||
| `notifications.alert_missing_reports_days` | Days without reports before alerting | `2` | `3` |
|
| `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
|
### Cloudflare Integration
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ Follow-up:
|
|||||||
|
|
||||||
## Later Milestones
|
## 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.
|
- DNS health and Cloudflare read-only inspection.
|
||||||
- Guided setup and operator health screens.
|
- Guided setup and operator health screens.
|
||||||
- Forensic/RUF report support.
|
- Forensic/RUF report support.
|
||||||
|
|||||||
+1
-1
@@ -116,9 +116,9 @@ Delivered:
|
|||||||
- Notification settings UI can save Apprise targets, keeps target URLs redacted after save, and can send a test notification.
|
- 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.
|
- 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.
|
- 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:
|
Planned:
|
||||||
- Daily/weekly summary notifications.
|
|
||||||
- Alert history.
|
- Alert history.
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
|
|||||||
+1
-1
@@ -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 release checklist covering migrations, tests, and smoke checks
|
||||||
- [x] Add Apprise notification delivery and test notification support
|
- [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
|
- [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
|
- [ ] Add alert history
|
||||||
- [ ] DNS health guidance and Cloudflare read-only inspection
|
- [ ] DNS health guidance and Cloudflare read-only inspection
|
||||||
- [ ] Guided setup and operator health pages
|
- [ ] Guided setup and operator health pages
|
||||||
|
|||||||
@@ -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
|
Use **Check Alerts** to preview the current alert count, or **Send Alerts Now** to
|
||||||
send the current alert summary through the configured Apprise targets.
|
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
|
### Integration Notifications
|
||||||
|
|
||||||
Apprise supports email, Slack, Teams, Discord, generic webhooks, and many other
|
Apprise supports email, Slack, Teams, Discord, generic webhooks, and many other
|
||||||
|
|||||||
Reference in New Issue
Block a user