From af66c93829f0f3407c9af4aea8f3f435c6d41be2 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Fri, 22 May 2026 23:18:32 +0200 Subject: [PATCH] feat: add alert history --- README.md | 30 +++++ backend/alembic/env.py | 10 +- .../a7b8c9d0e1f2_add_alert_history.py | 107 +++++++++++++++ backend/app/api/api_v1/endpoints/settings.py | 22 ++- backend/app/main.py | 1 + backend/app/models/alert.py | 33 +++++ backend/app/services/alert_history.py | 127 ++++++++++++++++++ backend/app/services/alert_rules.py | 2 + backend/app/services/summary_notifications.py | 2 + backend/app/templates/settings.html | 73 ++++++++++ backend/app/tests/conftest.py | 1 + backend/app/tests/test_settings.py | 53 ++++++++ docs/deployment/configuration.md | 4 + docs/development/roadmap.md | 2 +- docs/milestones.md | 6 +- docs/todo.md | 2 +- docs/user_guide/settings.md | 6 + 17 files changed, 469 insertions(+), 12 deletions(-) create mode 100644 backend/alembic/versions/a7b8c9d0e1f2_add_alert_history.py create mode 100644 backend/app/models/alert.py create mode 100644 backend/app/services/alert_history.py diff --git a/README.md b/README.md index e6f12e5..efea994 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ You can now: - Integration with [Apprise](https://github.com/caronc/apprise) - Email, Slack, webhook, and more - Alert on new failures, compliance drops, or unknown senders +- Daily and weekly DMARC summaries +- Alert history for active and resolved alerts ### 🔐 User Management - Built-in authentication via **FastAPI Users** @@ -100,6 +102,34 @@ Then visit [http://localhost:8080](http://localhost:8080) --- +## 🚨 Integration with Apprise + +DMARQ sends notifications through Apprise target URLs configured in +**Settings** > **Notifications**. Add one target URL per line, enable +notifications, and use **Send Test** to verify delivery. Apprise supports email, +Slack, Teams, Discord, generic webhooks, and many other targets. + +Notification settings include alert-rule toggles and thresholds for: + +- New sending sources +- Compliance-rate drops +- DMARC failures above a daily threshold +- Missing reports for monitored domains + +DMARQ can also send daily and weekly summaries. Use **Preview Summary** to see +the current summary payload, **Send Summary Now** for an immediate message, and +the daily/weekly toggles to enable scheduled delivery. + +Alert history is available in **Settings** > **Notifications** after alerts have +been evaluated or sent. History rows track active/resolved status, first seen, +last seen, observed count, and alert metadata. + +See [Settings](docs/user_guide/settings.md) and +[Configuration](docs/deployment/configuration.md) for examples and available +settings. + +--- + ## 📦 Requirements - DMARC aggregate reports (XML, ZIP, or GZIP format) diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 2dc0526..5c65b3e 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,10 +1,8 @@ import os from logging.config import fileConfig -from sqlalchemy import engine_from_config -from sqlalchemy import pool - from alembic import context +from sqlalchemy import engine_from_config, pool # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -22,8 +20,7 @@ if database_url: config.set_main_option("sqlalchemy.url", _make_sync_db_url(database_url)) -# Import all models so that autogenerate can detect them -from app.core.database import Base # noqa: E402 +import app.models.alert # noqa: E402, F401 import app.models.domain # noqa: E402, F401 import app.models.mail_source # noqa: E402, F401 import app.models.mail_source_import # noqa: E402, F401 @@ -31,6 +28,9 @@ import app.models.report # noqa: E402, F401 import app.models.setting # noqa: E402, F401 import app.models.user # noqa: E402, F401 +# Import all models so that autogenerate can detect them +from app.core.database import Base # noqa: E402 + target_metadata = Base.metadata diff --git a/backend/alembic/versions/a7b8c9d0e1f2_add_alert_history.py b/backend/alembic/versions/a7b8c9d0e1f2_add_alert_history.py new file mode 100644 index 0000000..b96967f --- /dev/null +++ b/backend/alembic/versions/a7b8c9d0e1f2_add_alert_history.py @@ -0,0 +1,107 @@ +"""add alert history + +Revision ID: a7b8c9d0e1f2 +Revises: f6a7b8c9d0e1 +Create Date: 2026-05-22 23:12:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a7b8c9d0e1f2" +down_revision: Union[str, Sequence[str], None] = "f6a7b8c9d0e1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create persisted alert history rows.""" + op.create_table( + "alert_history", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("fingerprint", sa.String(length=64), nullable=False), + sa.Column("rule", sa.String(), nullable=False), + sa.Column("severity", sa.String(), nullable=False), + sa.Column("domain", sa.String(), nullable=True), + sa.Column("title", sa.String(), nullable=False), + sa.Column("detail", sa.Text(), nullable=False), + sa.Column("payload", sa.Text(), nullable=True), + sa.Column("observed_count", sa.Integer(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("first_seen_at", sa.DateTime(), nullable=False), + sa.Column("last_seen_at", sa.DateTime(), nullable=False), + sa.Column("resolved_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("fingerprint"), + ) + op.create_index(op.f("ix_alert_history_id"), "alert_history", ["id"], unique=False) + op.create_index( + op.f("ix_alert_history_fingerprint"), + "alert_history", + ["fingerprint"], + unique=False, + ) + op.create_index(op.f("ix_alert_history_rule"), "alert_history", ["rule"], unique=False) + op.create_index( + op.f("ix_alert_history_severity"), + "alert_history", + ["severity"], + unique=False, + ) + op.create_index(op.f("ix_alert_history_domain"), "alert_history", ["domain"], unique=False) + op.create_index( + op.f("ix_alert_history_is_active"), + "alert_history", + ["is_active"], + unique=False, + ) + op.create_index( + op.f("ix_alert_history_first_seen_at"), + "alert_history", + ["first_seen_at"], + unique=False, + ) + op.create_index( + op.f("ix_alert_history_last_seen_at"), + "alert_history", + ["last_seen_at"], + unique=False, + ) + op.create_index( + op.f("ix_alert_history_resolved_at"), + "alert_history", + ["resolved_at"], + unique=False, + ) + op.create_index( + "ix_alert_history_active_last_seen", + "alert_history", + ["is_active", "last_seen_at"], + unique=False, + ) + op.create_index( + "ix_alert_history_rule_domain", + "alert_history", + ["rule", "domain"], + unique=False, + ) + + +def downgrade() -> None: + """Drop persisted alert history rows.""" + op.drop_index("ix_alert_history_rule_domain", table_name="alert_history") + op.drop_index("ix_alert_history_active_last_seen", table_name="alert_history") + op.drop_index(op.f("ix_alert_history_resolved_at"), table_name="alert_history") + op.drop_index(op.f("ix_alert_history_last_seen_at"), table_name="alert_history") + op.drop_index(op.f("ix_alert_history_first_seen_at"), table_name="alert_history") + op.drop_index(op.f("ix_alert_history_is_active"), table_name="alert_history") + op.drop_index(op.f("ix_alert_history_domain"), table_name="alert_history") + op.drop_index(op.f("ix_alert_history_severity"), table_name="alert_history") + op.drop_index(op.f("ix_alert_history_rule"), table_name="alert_history") + op.drop_index(op.f("ix_alert_history_fingerprint"), table_name="alert_history") + op.drop_index(op.f("ix_alert_history_id"), table_name="alert_history") + op.drop_table("alert_history") diff --git a/backend/app/api/api_v1/endpoints/settings.py b/backend/app/api/api_v1/endpoints/settings.py index 404de92..ea6e110 100644 --- a/backend/app/api/api_v1/endpoints/settings.py +++ b/backend/app/api/api_v1/endpoints/settings.py @@ -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_history import list_alert_history, record_alert_evaluation 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 @@ -322,6 +323,12 @@ class AlertNotificationResponse(BaseModel): notification: Dict[str, Any] +class AlertHistoryResponse(BaseModel): + """Persisted alert history response.""" + + history: List[Dict[str, Any]] + + class SummaryResponse(BaseModel): """Current DMARC summary notification preview.""" @@ -387,7 +394,9 @@ async def evaluate_notification_alerts( ) -> AlertRulesResponse: """Evaluate enabled notification alert rules against current DMARC data.""" _seed_defaults(db) - return {"alerts": evaluate_alert_rules(db)} + alerts = evaluate_alert_rules(db) + record_alert_evaluation(db, alerts) + return {"alerts": alerts} @router.post("/notifications/alerts/send", response_model=AlertNotificationResponse) @@ -407,6 +416,17 @@ async def send_notification_alerts( return result +@router.get("/notifications/alerts/history", response_model=AlertHistoryResponse) +async def get_notification_alert_history( + active: Optional[bool] = None, + limit: int = 50, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> AlertHistoryResponse: + """Return persisted alert history rows.""" + return {"history": list_alert_history(db, active=active, limit=max(1, min(limit, 200)))} + + @router.get("/notifications/summary", response_model=SummaryResponse) async def preview_notification_summary( period: str = "daily", diff --git a/backend/app/main.py b/backend/app/main.py index 658b53e..0b08498 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from fastapi.responses import FileResponse, HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +import app.models.alert # noqa: F401 – ensure AlertHistory table is registered import app.models.domain # noqa: F401 – ensure Domain/UserDomain tables are registered import app.models.mail_source_import # noqa: F401 – ensure import history table is registered import app.models.report # noqa: F401 – ensure DMARCReport/ReportRecord tables are registered diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py new file mode 100644 index 0000000..0affe21 --- /dev/null +++ b/backend/app/models/alert.py @@ -0,0 +1,33 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, Index, Integer, String, Text + +from app.core.database import Base + + +class AlertHistory(Base): + """Persisted alert lifecycle record.""" + + __tablename__ = "alert_history" + + id = Column(Integer, primary_key=True, index=True) + fingerprint = Column(String(64), unique=True, nullable=False, index=True) + rule = Column(String, nullable=False, index=True) + severity = Column(String, nullable=False, index=True) + domain = Column(String, nullable=True, index=True) + title = Column(String, nullable=False) + detail = Column(Text, nullable=False) + payload = Column(Text, nullable=True) + observed_count = Column(Integer, nullable=False, default=1) + is_active = Column(Boolean, nullable=False, default=True, index=True) + first_seen_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + last_seen_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + resolved_at = Column(DateTime, nullable=True, index=True) + + __table_args__ = ( + Index("ix_alert_history_active_last_seen", "is_active", "last_seen_at"), + Index("ix_alert_history_rule_domain", "rule", "domain"), + ) + + def __repr__(self): + return f"" diff --git a/backend/app/services/alert_history.py b/backend/app/services/alert_history.py new file mode 100644 index 0000000..c850f61 --- /dev/null +++ b/backend/app/services/alert_history.py @@ -0,0 +1,127 @@ +"""Persistence helpers for alert history.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy.orm import Session + +from app.models.alert import AlertHistory + + +def _json_dumps(value: Dict[str, Any]) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) + + +def alert_fingerprint(alert: Dict[str, Any]) -> str: + """Return a stable fingerprint for one alert signal.""" + identity = { + "rule": alert.get("rule"), + "domain": alert.get("domain"), + "source_ip": alert.get("source_ip"), + "threshold": alert.get("threshold"), + } + return hashlib.sha256(_json_dumps(identity).encode("utf-8")).hexdigest() + + +def _row_to_dict(row: AlertHistory) -> Dict[str, Any]: + payload = {} + if row.payload: + try: + payload = json.loads(row.payload) + except json.JSONDecodeError: + payload = {} + return { + "id": row.id, + "fingerprint": row.fingerprint, + "rule": row.rule, + "severity": row.severity, + "domain": row.domain, + "title": row.title, + "detail": row.detail, + "payload": payload, + "observed_count": row.observed_count, + "is_active": row.is_active, + "first_seen_at": row.first_seen_at.isoformat() if row.first_seen_at else None, + "last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None, + "resolved_at": row.resolved_at.isoformat() if row.resolved_at else None, + } + + +def record_alert_evaluation( + db: Session, + alerts: List[Dict[str, Any]], + *, + observed_at: Optional[datetime] = None, +) -> List[Dict[str, Any]]: + """Upsert current alerts and resolve active alerts that are no longer present.""" + timestamp = observed_at or datetime.utcnow() + fingerprints = {alert_fingerprint(alert): alert for alert in alerts} + + if fingerprints: + existing_rows = ( + db.query(AlertHistory) + .filter(AlertHistory.fingerprint.in_(list(fingerprints.keys()))) + .all() + ) + else: + existing_rows = [] + existing_by_fingerprint = {row.fingerprint: row for row in existing_rows} + + for fingerprint, alert in fingerprints.items(): + row = existing_by_fingerprint.get(fingerprint) + if row is None: + row = AlertHistory( + fingerprint=fingerprint, + rule=str(alert.get("rule") or "unknown"), + severity=str(alert.get("severity") or "warning"), + domain=alert.get("domain"), + title=str(alert.get("title") or "DMARC alert"), + detail=str(alert.get("detail") or ""), + payload=_json_dumps(alert), + observed_count=1, + is_active=True, + first_seen_at=timestamp, + last_seen_at=timestamp, + ) + db.add(row) + continue + + row.rule = str(alert.get("rule") or row.rule) + row.severity = str(alert.get("severity") or row.severity) + row.domain = alert.get("domain") + row.title = str(alert.get("title") or row.title) + row.detail = str(alert.get("detail") or row.detail) + row.payload = _json_dumps(alert) + row.observed_count = int(row.observed_count or 0) + 1 + row.is_active = True + row.last_seen_at = timestamp + row.resolved_at = None + + active_rows = db.query(AlertHistory).filter(AlertHistory.is_active == True).all() # noqa: E712 + for row in active_rows: + if row.fingerprint not in fingerprints: + row.is_active = False + row.resolved_at = timestamp + + db.commit() + return list_alert_history(db, limit=max(50, len(alerts))) + + +def list_alert_history( + db: Session, + *, + active: Optional[bool] = None, + limit: int = 50, +) -> List[Dict[str, Any]]: + """Return alert history rows ordered by most recent observation.""" + query = db.query(AlertHistory) + if active is not None: + query = query.filter(AlertHistory.is_active == active) + rows = ( + query.order_by(AlertHistory.last_seen_at.desc(), AlertHistory.id.desc()).limit(limit).all() + ) + return [_row_to_dict(row) for row in rows] diff --git a/backend/app/services/alert_rules.py b/backend/app/services/alert_rules.py index 829f969..ba838ee 100644 --- a/backend/app/services/alert_rules.py +++ b/backend/app/services/alert_rules.py @@ -11,6 +11,7 @@ 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.notifications import NotificationResult, send_notification @@ -237,6 +238,7 @@ def evaluate_alert_rules(db: Session) -> List[Dict[str, Any]]: 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) + record_alert_evaluation(db, alerts) if not alerts: return { "alerts": [], diff --git a/backend/app/services/summary_notifications.py b/backend/app/services/summary_notifications.py index b0667df..d39083f 100644 --- a/backend/app/services/summary_notifications.py +++ b/backend/app/services/summary_notifications.py @@ -12,6 +12,7 @@ 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 @@ -237,6 +238,7 @@ def format_summary_body(summary: Dict[str, Any]) -> str: 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)" diff --git a/backend/app/templates/settings.html b/backend/app/templates/settings.html index 6a4931d..d7730d7 100644 --- a/backend/app/templates/settings.html +++ b/backend/app/templates/settings.html @@ -368,6 +368,47 @@ +
+
+

Alert History

+ +
+ + + +
+ +
+
+