Files
gh-christianlouis-dmarq/backend/app/services/summary_notifications.py
T
Christian Krakau-Louis af66c93829 feat: add alert history
2026-05-22 23:18:32 +02:00

302 lines
10 KiB
Python

"""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_history import record_alert_evaluation
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)
record_alert_evaluation(db, summary["alerts"])
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