302 lines
10 KiB
Python
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
|