Merge PR #128: harden notification settings

Harden notification settings for Milestone 6.
This commit is contained in:
Christian Krakau-Louis
2026-05-23 00:59:45 +02:00
committed by GitHub
11 changed files with 964 additions and 22 deletions
+8 -3
View File
@@ -108,7 +108,8 @@ Then visit [http://localhost:8080](http://localhost:8080)
DMARQ sends notifications through Apprise target URLs configured in DMARQ sends notifications through Apprise target URLs configured in
**Settings** > **Notifications**. Add one target URL per line, enable **Settings** > **Notifications**. Add one target URL per line, enable
notifications, and use **Send Test** to verify delivery. Apprise supports email, notifications, and use **Send Test** to verify delivery. Target URLs are
encrypted in the database and redacted in API responses. Apprise supports email,
Slack, Teams, Discord, generic webhooks, and many other targets. Slack, Teams, Discord, generic webhooks, and many other targets.
Notification settings include alert-rule toggles and thresholds for: Notification settings include alert-rule toggles and thresholds for:
@@ -120,11 +121,15 @@ Notification settings include alert-rule toggles and thresholds for:
DMARQ can also send daily and weekly summaries. Use **Preview Summary** to see 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 current summary payload, **Send Summary Now** for an immediate message, and
the daily/weekly toggles to enable scheduled delivery. the daily/weekly toggles to enable scheduled delivery. Outbound messages are
rate-limited by the configured cooldown and email addresses are redacted by
default before delivery.
Alert history is available in **Settings** > **Notifications** after alerts have Alert history is available in **Settings** > **Notifications** after alerts have
been evaluated or sent. History rows track active/resolved status, first seen, been evaluated or sent. History rows track active/resolved status, first seen,
last seen, observed count, and alert metadata. last seen, observed count, and alert metadata. Notification and alert-rule
configuration changes are recorded in the configuration audit trail without
storing raw notification secrets.
See [Settings](docs/user_guide/settings.md) and See [Settings](docs/user_guide/settings.md) and
[Configuration](docs/deployment/configuration.md) for examples and available [Configuration](docs/deployment/configuration.md) for examples and available
@@ -0,0 +1,82 @@
"""add alert configuration audit
Revision ID: b9c0d1e2f3a4
Revises: b8c9d0e1f2a3
Create Date: 2026-05-23 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b9c0d1e2f3a4"
down_revision: Union[str, Sequence[str], None] = "b8c9d0e1f2a3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create alert configuration audit trail rows."""
op.create_table(
"alert_configuration_audit",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("key", sa.String(length=100), nullable=False),
sa.Column("old_value", sa.Text(), nullable=True),
sa.Column("new_value", sa.Text(), nullable=True),
sa.Column("changed_by", sa.String(length=100), nullable=True),
sa.Column("auth_type", sa.String(length=50), nullable=True),
sa.Column("changed_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_alert_configuration_audit_id"),
"alert_configuration_audit",
["id"],
unique=False,
)
op.create_index(
op.f("ix_alert_configuration_audit_key"),
"alert_configuration_audit",
["key"],
unique=False,
)
op.create_index(
op.f("ix_alert_configuration_audit_changed_by"),
"alert_configuration_audit",
["changed_by"],
unique=False,
)
op.create_index(
op.f("ix_alert_configuration_audit_changed_at"),
"alert_configuration_audit",
["changed_at"],
unique=False,
)
op.create_index(
"ix_alert_configuration_audit_key_changed_at",
"alert_configuration_audit",
["key", "changed_at"],
unique=False,
)
def downgrade() -> None:
"""Drop alert configuration audit trail rows."""
op.drop_index(
"ix_alert_configuration_audit_key_changed_at",
table_name="alert_configuration_audit",
)
op.drop_index(
op.f("ix_alert_configuration_audit_changed_at"),
table_name="alert_configuration_audit",
)
op.drop_index(
op.f("ix_alert_configuration_audit_changed_by"),
table_name="alert_configuration_audit",
)
op.drop_index(op.f("ix_alert_configuration_audit_key"), table_name="alert_configuration_audit")
op.drop_index(op.f("ix_alert_configuration_audit_id"), table_name="alert_configuration_audit")
op.drop_table("alert_configuration_audit")
+134 -5
View File
@@ -18,10 +18,16 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.credential_encryption import decrypt_secret, encrypt_secret, is_encrypted_secret
from app.core.database import get_db from app.core.database import get_db
from app.core.security import require_admin_auth from app.core.security import require_admin_auth
from app.models.setting import Setting from app.models.setting import Setting
from app.services.alert_history import list_alert_history, record_alert_evaluation from app.services.alert_history import (
list_alert_config_audit,
list_alert_history,
record_alert_config_change,
record_alert_evaluation,
)
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 from app.services.summary_notifications import build_summary, send_summary_notification
@@ -130,6 +136,27 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [
"value_type": "string", "value_type": "string",
"category": "notifications", "category": "notifications",
}, },
{
"key": "notifications.min_send_interval_minutes",
"value": "15",
"description": "Minimum minutes between outbound notification deliveries",
"value_type": "integer",
"category": "notifications",
},
{
"key": "notifications.redact_pii_enabled",
"value": "true",
"description": "Redact email addresses from outbound notification titles and bodies",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.last_sent_at",
"value": "",
"description": "Internal timestamp for outbound notification rate limiting",
"value_type": "string",
"category": "notifications",
},
{ {
"key": "notifications.alert_new_sources_enabled", "key": "notifications.alert_new_sources_enabled",
"value": "true", "value": "true",
@@ -250,6 +277,7 @@ def _seed_defaults(db: Session) -> None:
category=defaults["category"], category=defaults["category"],
) )
) )
_migrate_plaintext_secret_settings(db)
db.commit() db.commit()
@@ -257,6 +285,55 @@ def _get_setting(key: str, db: Session) -> Optional[Setting]:
return db.query(Setting).filter(Setting.key == key).first() return db.query(Setting).filter(Setting.key == key).first()
def _migrate_plaintext_secret_settings(db: Session) -> None:
"""Encrypt legacy plaintext secret settings opportunistically."""
rows = db.query(Setting).filter(Setting.key.in_(_SECRET_KEYS)).all()
for row in rows:
if row.value and not is_encrypted_secret(row.value):
row.value = encrypt_secret(row.value)
def _stored_value_for_setting(key: str, value: Optional[str]) -> Optional[str]:
if key in _SECRET_KEYS:
return encrypt_secret(value)
return value
def _plain_value_for_setting(key: str, value: Optional[str]) -> Optional[str]:
if key not in _SECRET_KEYS:
return value
return decrypt_secret(value)
def _audit_value_for_setting(key: str, value: Optional[str]) -> Optional[str]:
if key in _SECRET_KEYS:
return "[redacted]" if value else ""
return value
def _should_audit_setting(key: str) -> bool:
return key.startswith("notifications.")
def _audit_setting_change(
db: Session,
*,
key: str,
old_plain: Optional[str],
new_plain: Optional[str],
auth_context: Optional[Dict[str, Any]],
) -> None:
if not _should_audit_setting(key) or old_plain == new_plain:
return
record_alert_config_change(
db,
key=key,
old_value=_audit_value_for_setting(key, old_plain),
new_value=_audit_value_for_setting(key, new_plain),
auth_context=auth_context,
)
def _row_to_dict(row: Setting, redact_secrets: bool = True) -> Dict[str, Any]: def _row_to_dict(row: Setting, redact_secrets: bool = True) -> Dict[str, Any]:
value = row.value value = row.value
if redact_secrets and row.key in _SECRET_KEYS and value: if redact_secrets and row.key in _SECRET_KEYS and value:
@@ -307,6 +384,7 @@ class NotificationTestResponse(BaseModel):
configured_targets: int = 0 configured_targets: int = 0
invalid_targets: int = 0 invalid_targets: int = 0
skipped: bool = False skipped: bool = False
rate_limited: bool = False
error: Optional[str] = None error: Optional[str] = None
@@ -329,6 +407,12 @@ class AlertHistoryResponse(BaseModel):
history: List[Dict[str, Any]] history: List[Dict[str, Any]]
class AlertConfigurationAuditResponse(BaseModel):
"""Persisted alert configuration audit response."""
audit: List[Dict[str, Any]]
class SummaryResponse(BaseModel): class SummaryResponse(BaseModel):
"""Current DMARC summary notification preview.""" """Current DMARC summary notification preview."""
@@ -427,6 +511,16 @@ async def get_notification_alert_history(
return {"history": list_alert_history(db, active=active, limit=max(1, min(limit, 200)))} return {"history": list_alert_history(db, active=active, limit=max(1, min(limit, 200)))}
@router.get("/notifications/config-audit", response_model=AlertConfigurationAuditResponse)
async def get_notification_config_audit(
limit: int = 50,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> AlertConfigurationAuditResponse:
"""Return recent notification and alert-rule configuration changes."""
return {"audit": list_alert_config_audit(db, limit=max(1, min(limit, 200)))}
@router.get("/notifications/summary", response_model=SummaryResponse) @router.get("/notifications/summary", response_model=SummaryResponse)
async def preview_notification_summary( async def preview_notification_summary(
period: str = "daily", period: str = "daily",
@@ -495,23 +589,41 @@ async def update_setting(
) -> SettingResponse: ) -> SettingResponse:
"""Update or create a single setting.""" """Update or create a single setting."""
row = _get_setting(key, db) row = _get_setting(key, db)
new_value = payload.value
if row is None: if row is None:
# Find matching default metadata # Find matching default metadata
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None) default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
new_plain = _plain_value_for_setting(key, new_value)
row = Setting( row = Setting(
key=key, key=key,
value=payload.value, value=_stored_value_for_setting(key, new_value),
description=default_meta["description"] if default_meta else None, description=default_meta["description"] if default_meta else None,
value_type=default_meta["value_type"] if default_meta else "string", value_type=default_meta["value_type"] if default_meta else "string",
category=default_meta["category"] if default_meta else "general", category=default_meta["category"] if default_meta else "general",
) )
db.add(row) db.add(row)
_audit_setting_change(
db,
key=key,
old_plain=None,
new_plain=new_plain,
auth_context=_auth,
)
else: else:
# For secret keys, only update if not the redacted placeholder # For secret keys, only update if not the redacted placeholder
if key in _SECRET_KEYS and payload.value == "**redacted**": if key in _SECRET_KEYS and payload.value == "**redacted**":
db.refresh(row) db.refresh(row)
return _row_to_dict(row) return _row_to_dict(row)
row.value = payload.value old_plain = _plain_value_for_setting(key, row.value)
new_plain = _plain_value_for_setting(key, new_value)
row.value = _stored_value_for_setting(key, new_value)
_audit_setting_change(
db,
key=key,
old_plain=old_plain,
new_plain=new_plain,
auth_context=_auth,
)
db.commit() db.commit()
db.refresh(row) db.refresh(row)
return _row_to_dict(row) return _row_to_dict(row)
@@ -533,20 +645,37 @@ async def bulk_update_settings(
row = _get_setting(key, db) row = _get_setting(key, db)
if row is None: if row is None:
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None) default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
new_plain = _plain_value_for_setting(key, value)
row = Setting( row = Setting(
key=key, key=key,
value=value, value=_stored_value_for_setting(key, value),
description=default_meta["description"] if default_meta else None, description=default_meta["description"] if default_meta else None,
value_type=default_meta["value_type"] if default_meta else "string", value_type=default_meta["value_type"] if default_meta else "string",
category=default_meta["category"] if default_meta else "general", category=default_meta["category"] if default_meta else "general",
) )
db.add(row) db.add(row)
_audit_setting_change(
db,
key=key,
old_plain=None,
new_plain=new_plain,
auth_context=_auth,
)
else: else:
# Skip secret placeholder updates # Skip secret placeholder updates
if key in _SECRET_KEYS and value == "**redacted**": if key in _SECRET_KEYS and value == "**redacted**":
results.append(_row_to_dict(row)) results.append(_row_to_dict(row))
continue continue
row.value = value old_plain = _plain_value_for_setting(key, row.value)
new_plain = _plain_value_for_setting(key, value)
row.value = _stored_value_for_setting(key, value)
_audit_setting_change(
db,
key=key,
old_plain=old_plain,
new_plain=new_plain,
auth_context=_auth,
)
results.append(_row_to_dict(row)) results.append(_row_to_dict(row))
db.commit() db.commit()
# Re-read rows to get updated_at timestamps # Re-read rows to get updated_at timestamps
+19
View File
@@ -31,3 +31,22 @@ class AlertHistory(Base):
def __repr__(self): def __repr__(self):
return f"<AlertHistory {self.rule} active={self.is_active}>" return f"<AlertHistory {self.rule} active={self.is_active}>"
class AlertConfigurationAudit(Base):
"""Audit trail for notification and alert-rule configuration changes."""
__tablename__ = "alert_configuration_audit"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), nullable=False, index=True)
old_value = Column(Text, nullable=True)
new_value = Column(Text, nullable=True)
changed_by = Column(String(100), nullable=True, index=True)
auth_type = Column(String(50), nullable=True)
changed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
__table_args__ = (Index("ix_alert_configuration_audit_key_changed_at", "key", "changed_at"),)
def __repr__(self):
return f"<AlertConfigurationAudit {self.key}>"
+62 -1
View File
@@ -9,7 +9,7 @@ from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.alert import AlertHistory from app.models.alert import AlertConfigurationAudit, AlertHistory
def _json_dumps(value: Dict[str, Any]) -> str: def _json_dumps(value: Dict[str, Any]) -> str:
@@ -125,3 +125,64 @@ def list_alert_history(
query.order_by(AlertHistory.last_seen_at.desc(), AlertHistory.id.desc()).limit(limit).all() query.order_by(AlertHistory.last_seen_at.desc(), AlertHistory.id.desc()).limit(limit).all()
) )
return [_row_to_dict(row) for row in rows] return [_row_to_dict(row) for row in rows]
def _actor_from_auth(auth_context: Optional[Dict[str, Any]]) -> Dict[str, Optional[str]]:
auth_context = auth_context or {}
user_id = auth_context.get("user_id")
if user_id is not None:
changed_by = str(user_id)
elif auth_context.get("payload", {}).get("sub"):
changed_by = str(auth_context["payload"]["sub"])
else:
changed_by = str(auth_context.get("auth_type") or "unknown")
return {
"changed_by": changed_by,
"auth_type": str(auth_context.get("auth_type") or "unknown"),
}
def record_alert_config_change(
db: Session,
*,
key: str,
old_value: Optional[str],
new_value: Optional[str],
auth_context: Optional[Dict[str, Any]] = None,
changed_at: Optional[datetime] = None,
) -> None:
"""Record one sanitized alert/notification setting change."""
actor = _actor_from_auth(auth_context)
db.add(
AlertConfigurationAudit(
key=key,
old_value=old_value,
new_value=new_value,
changed_by=actor["changed_by"],
auth_type=actor["auth_type"],
changed_at=changed_at or datetime.utcnow(),
)
)
def _config_audit_row_to_dict(row: AlertConfigurationAudit) -> Dict[str, Any]:
return {
"id": row.id,
"key": row.key,
"old_value": row.old_value,
"new_value": row.new_value,
"changed_by": row.changed_by,
"auth_type": row.auth_type,
"changed_at": row.changed_at.isoformat() if row.changed_at else None,
}
def list_alert_config_audit(db: Session, *, limit: int = 50) -> List[Dict[str, Any]]:
"""Return recent alert/notification configuration changes."""
rows = (
db.query(AlertConfigurationAudit)
.order_by(AlertConfigurationAudit.changed_at.desc(), AlertConfigurationAudit.id.desc())
.limit(limit)
.all()
)
return [_config_audit_row_to_dict(row) for row in rows]
+146 -3
View File
@@ -4,11 +4,13 @@ from __future__ import annotations
import logging import logging
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
import apprise import apprise
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.credential_encryption import decrypt_secret
from app.models.setting import Setting from app.models.setting import Setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,14 +25,17 @@ class NotificationResult:
configured_targets: int = 0 configured_targets: int = 0
invalid_targets: int = 0 invalid_targets: int = 0
skipped: bool = False skipped: bool = False
rate_limited: bool = False
error: Optional[str] = None error: Optional[str] = None
def to_dict(self) -> Dict[str, object]: def to_dict(self) -> Dict[str, object]:
return asdict(self) return asdict(self)
def _truthy(value: Optional[str]) -> bool: def _truthy(value: Optional[str], default: bool = False) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on"} if value in (None, ""):
return default
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _split_apprise_urls(value: Optional[str]) -> List[str]: def _split_apprise_urls(value: Optional[str]) -> List[str]:
@@ -48,6 +53,130 @@ def _notification_settings(db: Session) -> Dict[str, Optional[str]]:
return {row.key: row.value for row in rows} return {row.key: row.value for row in rows}
def _decrypted_setting(settings: Dict[str, Optional[str]], key: str) -> Optional[str]:
try:
return decrypt_secret(settings.get(key))
except ValueError:
logger.exception("Encrypted notification setting could not be decrypted: %s", key)
return None
def _int_setting(value: Optional[str], default: int) -> int:
try:
return int(value or default)
except (TypeError, ValueError):
return default
def _parse_timestamp(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _set_notification_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,
value_type="string",
category="notifications",
)
db.add(row)
else:
row.value = value
def _rate_limit_result(settings: Dict[str, Optional[str]]) -> Optional[NotificationResult]:
interval_minutes = max(
0, _int_setting(settings.get("notifications.min_send_interval_minutes"), 15)
)
if interval_minutes <= 0:
return None
last_sent_at = _parse_timestamp(settings.get("notifications.last_sent_at"))
if last_sent_at is None:
return None
next_allowed = last_sent_at + timedelta(minutes=interval_minutes)
now = datetime.now(timezone.utc)
if now >= next_allowed:
return None
retry_after = max(1, int((next_allowed - now).total_seconds() // 60) + 1)
return NotificationResult(
success=False,
skipped=True,
rate_limited=True,
message=f"Notification rate limit active. Try again in about {retry_after} minute(s).",
error="rate_limited",
)
_EMAIL_LOCAL_CHARS = frozenset(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._%+-"
)
_EMAIL_DOMAIN_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-")
_EMAIL_LEADING_PUNCTUATION = frozenset("\"'(<[{")
_EMAIL_TRAILING_PUNCTUATION = frozenset("\"'.,;:!?)]}>")
def _redact_email_token(token: str) -> str:
leading = ""
trailing = ""
core = token
while core and core[0] in _EMAIL_LEADING_PUNCTUATION:
leading += core[0]
core = core[1:]
while core and core[-1] in _EMAIL_TRAILING_PUNCTUATION:
trailing = core[-1] + trailing
core = core[:-1]
if core.count("@") != 1:
return token
local_part, domain_part = core.split("@", 1)
domain_labels = domain_part.split(".")
if (
not local_part
or not domain_part
or len(domain_labels) < 2
or len(domain_labels[-1]) < 2
or not domain_labels[-1].isalpha()
or any(not label for label in domain_labels)
or any(char not in _EMAIL_LOCAL_CHARS for char in local_part)
or any(char not in _EMAIL_DOMAIN_CHARS for char in domain_part)
):
return token
return f"{leading}[redacted-email]@{domain_part}{trailing}"
def redact_notification_text(value: str) -> str:
"""Remove common PII from outbound notification text."""
redacted = []
token = []
for char in value:
if char.isspace():
if token:
redacted.append(_redact_email_token("".join(token)))
token = []
redacted.append(char)
else:
token.append(char)
if token:
redacted.append(_redact_email_token("".join(token)))
return "".join(redacted)
def _add_apprise_targets(notifier: apprise.Apprise, urls: List[str]) -> Tuple[int, int]: def _add_apprise_targets(notifier: apprise.Apprise, urls: List[str]) -> Tuple[int, int]:
configured_targets = 0 configured_targets = 0
invalid_targets = 0 invalid_targets = 0
@@ -81,7 +210,11 @@ def send_notification(
message="Notifications are disabled.", message="Notifications are disabled.",
) )
urls = _split_apprise_urls(settings.get("notifications.apprise_urls")) rate_limit = None if force else _rate_limit_result(settings)
if rate_limit:
return rate_limit
urls = _split_apprise_urls(_decrypted_setting(settings, "notifications.apprise_urls"))
if not urls: if not urls:
return NotificationResult( return NotificationResult(
success=False, success=False,
@@ -99,6 +232,9 @@ def send_notification(
) )
try: try:
if _truthy(settings.get("notifications.redact_pii_enabled"), default=True):
title = redact_notification_text(title)
body = redact_notification_text(body)
success = bool(notifier.notify(title=title, body=body)) success = bool(notifier.notify(title=title, body=body))
except Exception: # pylint: disable=broad-exception-caught except Exception: # pylint: disable=broad-exception-caught
logger.exception("Apprise notification delivery failed.") logger.exception("Apprise notification delivery failed.")
@@ -119,6 +255,13 @@ def send_notification(
error="not_delivered", error="not_delivered",
) )
_set_notification_setting(
db,
"notifications.last_sent_at",
datetime.now(timezone.utc).isoformat(),
)
db.commit()
return NotificationResult( return NotificationResult(
success=True, success=True,
message="Notification sent.", message="Notification sent.",
+88 -1
View File
@@ -229,12 +229,29 @@
<textarea x-model="s['notifications.apprise_urls']" <textarea x-model="s['notifications.apprise_urls']"
class="textarea textarea-bordered w-full min-h-32 font-mono text-sm" class="textarea textarea-bordered w-full min-h-32 font-mono text-sm"
placeholder="mailto://user:password@example.com&#10;slack://token-a/token-b/token-c/channel"></textarea> placeholder="mailto://user:password@example.com&#10;slack://token-a/token-b/token-c/channel"></textarea>
<label class="label"><span class="label-text-alt text-muted-foreground">One target per line. Values are redacted after saving.</span></label> <label class="label"><span class="label-text-alt text-muted-foreground">One target per line. Values are encrypted at rest and redacted after saving.</span></label>
</div> </div>
</div> </div>
</template> </template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Minimum Send Interval</span></label>
<input type="number" x-model.number="s['notifications.min_send_interval_minutes']"
class="input input-bordered w-full" min="0" />
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.redact_pii_enabled'] === 'true'"
@change="s['notifications.redact_pii_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Redact email addresses in messages</span>
</label>
</div>
</div>
<div class="border-t border-border pt-4 space-y-4"> <div class="border-t border-border pt-4 space-y-4">
<div> <div>
<h3 class="text-sm font-semibold">Alert Rules</h3> <h3 class="text-sm font-semibold">Alert Rules</h3>
@@ -409,6 +426,44 @@
</div> </div>
</div> </div>
<div class="border-t border-border pt-4 space-y-4">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold">Configuration Audit</h3>
<button type="button" class="btn btn-outline btn-sm" :disabled="loadingConfigAudit" @click="loadConfigAudit()">
<template x-if="!loadingConfigAudit">
<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 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
<path d="M3 3v5h5"></path>
<path d="M12 7v5l4 2"></path>
</svg>
</template>
<template x-if="loadingConfigAudit"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Refresh
</button>
</div>
<template x-if="configAudit.length === 0">
<div class="alert alert-info">
<span>No configuration changes recorded yet.</span>
</div>
</template>
<div class="space-y-2" x-show="configAudit.length > 0">
<template x-for="item in configAudit" :key="item.id">
<div class="rounded-md border border-border p-3">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-semibold" x-text="item.key"></div>
<div class="text-sm text-muted-foreground" x-text="`${item.old_value || '(empty)'} -> ${item.new_value || '(empty)'}`"></div>
</div>
<div class="text-xs text-muted-foreground" x-text="new Date(item.changed_at).toLocaleString()"></div>
</div>
</div>
</template>
</div>
</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()"> <button type="button" class="btn btn-outline btn-md" :disabled="saving || previewingSummary" @click="previewSummary()">
<template x-if="!previewingSummary"> <template x-if="!previewingSummary">
@@ -520,6 +575,8 @@ function settingsApp() {
summaryPreview: null, summaryPreview: null,
loadingAlertHistory: false, loadingAlertHistory: false,
alertHistory: [], alertHistory: [],
loadingConfigAudit: false,
configAudit: [],
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).
@@ -544,6 +601,7 @@ function settingsApp() {
rows.forEach(r => { map[r.key] = r.value ?? ''; }); rows.forEach(r => { map[r.key] = r.value ?? ''; });
this.s = map; this.s = map;
await this.loadAlertHistory(false); await this.loadAlertHistory(false);
await this.loadConfigAudit(false);
} catch (err) { } catch (err) {
this.showFlash('Error loading settings: ' + err.message, false); this.showFlash('Error loading settings: ' + err.message, false);
} }
@@ -567,6 +625,9 @@ function settingsApp() {
} else { } else {
const rows = await res.json(); const rows = await res.json();
rows.forEach(r => { this.s[r.key] = r.value ?? ''; }); rows.forEach(r => { this.s[r.key] = r.value ?? ''; });
if (category === 'notifications') {
await this.loadConfigAudit(false);
}
this.showFlash('Settings saved successfully.', true); this.showFlash('Settings saved successfully.', true);
} }
} catch (err) { } catch (err) {
@@ -717,6 +778,32 @@ function settingsApp() {
} }
}, },
async loadConfigAudit(showMessage = true) {
this.loadingConfigAudit = true;
try {
const res = await fetch('/api/v1/settings/notifications/config-audit?limit=10', {
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (showMessage) {
this.showFlash('Configuration audit failed: ' + (data.detail || res.statusText), false);
}
} else {
this.configAudit = data.audit || [];
if (showMessage) {
this.showFlash('Configuration audit refreshed.', true);
}
}
} catch (err) {
if (showMessage) {
this.showFlash('Error loading configuration audit: ' + err.message, false);
}
} finally {
this.loadingConfigAudit = false;
}
},
showFlash(msg, ok) { showFlash(msg, ok) {
this.flashMsg = msg; this.flashMsg = msg;
this.flashOk = ok; this.flashOk = ok;
+402 -2
View File
@@ -7,11 +7,13 @@ from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.alert import AlertHistory from app.core.credential_encryption import decrypt_secret, is_encrypted_secret
from app.models.alert import AlertConfigurationAudit, AlertHistory
from app.models.domain import Domain 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.alert_history import list_alert_config_audit, record_alert_config_change
from app.services.notifications import NotificationResult, send_notification
from app.services.summary_notifications import send_due_scheduled_summaries from app.services.summary_notifications import send_due_scheduled_summaries
@@ -88,6 +90,13 @@ class TestSettingModel:
assert "dns.resolver" in repr(row) assert "dns.resolver" in repr(row)
assert "dns" in repr(row) assert "dns" in repr(row)
def test_alert_reprs(self):
alert = AlertHistory(rule="missing_reports", is_active=True)
audit = AlertConfigurationAudit(key="notifications.apprise_enabled")
assert "missing_reports" in repr(alert)
assert "notifications.apprise_enabled" in repr(audit)
class TestSettingsAPI: class TestSettingsAPI:
"""Integration tests for /api/v1/settings endpoints.""" """Integration tests for /api/v1/settings endpoints."""
@@ -112,6 +121,8 @@ class TestSettingsAPI:
assert "notifications.summary_weekly_enabled" in keys assert "notifications.summary_weekly_enabled" in keys
assert "notifications.summary_send_hour_utc" in keys assert "notifications.summary_send_hour_utc" in keys
assert "notifications.summary_weekday_utc" in keys assert "notifications.summary_weekday_utc" in keys
assert "notifications.min_send_interval_minutes" in keys
assert "notifications.redact_pii_enabled" 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."""
@@ -199,6 +210,48 @@ class TestSettingsAPI:
assert res.status_code == 200 assert res.status_code == 200
assert res.json()["value"] == "**redacted**" assert res.json()["value"] == "**redacted**"
def test_apprise_urls_are_encrypted_at_rest(
self,
authed_client: TestClient,
db_session: Session,
):
"""Apprise target URLs are encrypted in the settings table."""
target = "mailto://user:password@example.com"
authed_client.get("/api/v1/settings")
res = authed_client.put(
"/api/v1/settings/notifications.apprise_urls",
json={"value": target},
)
assert res.status_code == 200
row = db_session.query(Setting).filter(Setting.key == "notifications.apprise_urls").first()
assert row is not None
assert row.value != target
assert is_encrypted_secret(row.value)
assert decrypt_secret(row.value) == target
def test_legacy_plaintext_secret_setting_is_migrated_on_read(
self,
authed_client: TestClient,
db_session: Session,
):
"""Plaintext secret settings are encrypted the next time defaults are seeded."""
db_session.add(
Setting(
key="notifications.apprise_urls",
value="mailto://user:password@example.com",
category="notifications",
)
)
db_session.commit()
res = authed_client.get("/api/v1/settings")
assert res.status_code == 200
row = db_session.query(Setting).filter(Setting.key == "notifications.apprise_urls").first()
assert is_encrypted_secret(row.value)
def test_redacted_placeholder_does_not_overwrite(self, authed_client: TestClient): def test_redacted_placeholder_does_not_overwrite(self, authed_client: TestClient):
"""Sending **redacted** back to PUT should not overwrite the stored value.""" """Sending **redacted** back to PUT should not overwrite the stored value."""
authed_client.get("/api/v1/settings") authed_client.get("/api/v1/settings")
@@ -264,6 +317,204 @@ class TestSettingsAPI:
assert "password" not in str(data) assert "password" not in str(data)
assert FakeApprise.instances[0].messages[0]["title"] == "DMARQ test notification" assert FakeApprise.instances[0].messages[0]["title"] == "DMARQ test notification"
def test_notification_delivery_is_rate_limited(
self,
db_session: Session,
monkeypatch,
):
"""Non-forced notification sends respect the configured cooldown."""
class FakeApprise:
instances = []
def __init__(self):
self.messages = []
FakeApprise.instances.append(self)
def add(self, url):
return True
def notify(self, *, title, body):
self.messages.append({"title": title, "body": body})
return True
monkeypatch.setattr("app.services.notifications.apprise.Apprise", FakeApprise)
db_session.add_all(
[
Setting(
key="notifications.apprise_enabled",
value="true",
category="notifications",
),
Setting(
key="notifications.apprise_urls",
value="mailto://user:password@example.com",
category="notifications",
),
Setting(
key="notifications.min_send_interval_minutes",
value="15",
category="notifications",
),
]
)
db_session.commit()
first = send_notification(db_session, title="First", body="First body")
second = send_notification(db_session, title="Second", body="Second body")
assert first.success is True
assert second.success is False
assert second.rate_limited is True
assert second.error == "rate_limited"
assert sum(len(instance.messages) for instance in FakeApprise.instances) == 1
def test_notification_delivery_edges_are_sanitized(
self,
db_session: Session,
monkeypatch,
):
"""Notification delivery handles disabled, invalid, and failed target paths."""
class FalseApprise:
def add(self, url):
return False
class RaisingApprise:
def add(self, url):
return True
def notify(self, *, title, body):
raise RuntimeError("delivery failed")
class NotDeliveredApprise:
def add(self, url):
return True
def notify(self, *, title, body):
return False
assert send_notification(db_session, title="Off", body="Body").skipped is True
db_session.add_all(
[
Setting(
key="notifications.apprise_enabled",
value="true",
category="notifications",
),
Setting(
key="notifications.apprise_urls",
value="mailto://user:password@example.com",
category="notifications",
),
Setting(
key="notifications.min_send_interval_minutes",
value="not-an-integer",
category="notifications",
),
Setting(
key="notifications.last_sent_at",
value="not-a-date",
category="notifications",
),
]
)
db_session.commit()
monkeypatch.setattr("app.services.notifications.apprise.Apprise", FalseApprise)
invalid = send_notification(db_session, title="Invalid", body="Body")
assert invalid.success is False
assert invalid.invalid_targets == 1
monkeypatch.setattr("app.services.notifications.apprise.Apprise", RaisingApprise)
failed = send_notification(db_session, title="Raises", body="Body")
assert failed.error == "delivery_failed"
monkeypatch.setattr("app.services.notifications.apprise.Apprise", NotDeliveredApprise)
not_delivered = send_notification(db_session, title="No", body="Body")
assert not_delivered.error == "not_delivered"
def test_notification_decrypt_error_returns_no_targets(
self,
db_session: Session,
monkeypatch,
):
"""Unreadable encrypted target settings fail closed without exposing secrets."""
db_session.add_all(
[
Setting(
key="notifications.apprise_enabled",
value="true",
category="notifications",
),
Setting(
key="notifications.apprise_urls",
value="enc:v1:bad-token",
category="notifications",
),
]
)
db_session.commit()
def raise_value_error(value): # pylint: disable=unused-argument
raise ValueError("bad token")
monkeypatch.setattr("app.services.notifications.decrypt_secret", raise_value_error)
result = send_notification(db_session, title="Bad secret", body="Body")
assert result.success is False
assert result.message == "No notification targets are configured."
def test_notification_delivery_redacts_email_addresses(
self,
db_session: Session,
monkeypatch,
):
"""Outbound notification text redacts email addresses by default."""
class FakeApprise:
messages = []
def add(self, url):
return True
def notify(self, *, title, body):
self.messages.append({"title": title, "body": body})
return True
monkeypatch.setattr("app.services.notifications.apprise.Apprise", FakeApprise)
db_session.add_all(
[
Setting(
key="notifications.apprise_enabled",
value="true",
category="notifications",
),
Setting(
key="notifications.apprise_urls",
value="mailto://user:password@example.com",
category="notifications",
),
]
)
db_session.commit()
result = send_notification(
db_session,
title="Failure for admin@example.com",
body="Sample from alice@example.com failed DMARC.",
)
assert result.success is True
assert "admin@example.com" not in FakeApprise.messages[0]["title"]
assert "alice@example.com" not in FakeApprise.messages[0]["body"]
assert "[redacted-email]@example.com" in FakeApprise.messages[0]["body"]
redact_text = send_notification.__globals__["redact_notification_text"]
assert redact_text('"admin@example.com!"').strip('"') == "[redacted-email]@example.com!"
assert redact_text("not-an-email") == "not-an-email"
def test_test_notification_without_targets_returns_400(self, authed_client: TestClient): def test_test_notification_without_targets_returns_400(self, authed_client: TestClient):
"""Test notification returns a useful error when no target is configured.""" """Test notification returns a useful error when no target is configured."""
authed_client.get("/api/v1/settings") authed_client.get("/api/v1/settings")
@@ -469,6 +720,128 @@ class TestSettingsAPI:
assert resolved assert resolved
assert all(item["is_active"] is False for item in resolved) assert all(item["is_active"] is False for item in resolved)
def test_notification_alert_send_failure_returns_400(
self,
authed_client: TestClient,
monkeypatch,
):
"""Alert send endpoint surfaces notification delivery failures."""
def fake_send_current_alerts(db): # pylint: disable=unused-argument
return {
"alerts": [{"title": "Alert", "detail": "Detail"}],
"notification": {
"success": False,
"message": "No valid notification targets are configured.",
},
}
monkeypatch.setattr(
"app.api.api_v1.endpoints.settings.send_current_alerts",
fake_send_current_alerts,
)
res = authed_client.post("/api/v1/settings/notifications/alerts/send")
assert res.status_code == 400
assert res.json()["detail"]["notification"]["success"] is False
def test_notification_config_audit_records_sanitized_changes(
self,
authed_client: TestClient,
):
"""Notification setting changes create an audit trail without secret values."""
authed_client.get("/api/v1/settings")
res = authed_client.post(
"/api/v1/settings/bulk",
json={
"settings": {
"notifications.apprise_enabled": "true",
"notifications.apprise_urls": "mailto://user:password@example.com",
"notifications.alert_failure_threshold_count": "250",
}
},
)
assert res.status_code == 200
audit_res = authed_client.get("/api/v1/settings/notifications/config-audit")
assert audit_res.status_code == 200
audit = audit_res.json()["audit"]
keys = {item["key"] for item in audit}
assert "notifications.apprise_enabled" in keys
assert "notifications.apprise_urls" in keys
assert "notifications.alert_failure_threshold_count" in keys
secret_row = next(item for item in audit if item["key"] == "notifications.apprise_urls")
assert secret_row["new_value"] == "[redacted]"
assert "password" not in str(audit)
def test_notification_config_audit_actor_variants(
self,
db_session: Session,
):
"""Config audit actor detection covers session and JWT auth contexts."""
record_alert_config_change(
db_session,
key="notifications.apprise_enabled",
old_value="false",
new_value="true",
auth_context={"auth_type": "session", "user_id": 123},
)
record_alert_config_change(
db_session,
key="notifications.apprise_enabled",
old_value="true",
new_value="false",
auth_context={"auth_type": "jwt", "payload": {"sub": "admin@example.com"}},
)
db_session.commit()
audit = list_alert_config_audit(db_session, limit=2)
assert {row["changed_by"] for row in audit} == {"123", "admin@example.com"}
def test_bulk_update_upserts_and_preserves_redacted_secret(
self,
authed_client: TestClient,
db_session: Session,
):
"""Bulk settings handles new rows and redacted secret placeholders."""
authed_client.get("/api/v1/settings")
authed_client.put(
"/api/v1/settings/notifications.apprise_urls",
json={"value": "mailto://user:password@example.com"},
)
before = (
db_session.query(Setting)
.filter(Setting.key == "notifications.apprise_urls")
.first()
.value
)
res = authed_client.post(
"/api/v1/settings/bulk",
json={
"settings": {
"notifications.custom_notice": "enabled",
"notifications.apprise_urls": "**redacted**",
}
},
)
assert res.status_code == 200
after = (
db_session.query(Setting)
.filter(Setting.key == "notifications.apprise_urls")
.first()
.value
)
custom = (
db_session.query(Setting).filter(Setting.key == "notifications.custom_notice").first()
)
assert after == before
assert custom.value == "enabled"
def test_notification_summary_preview_returns_recent_activity( def test_notification_summary_preview_returns_recent_activity(
self, self,
authed_client: TestClient, authed_client: TestClient,
@@ -547,6 +920,33 @@ class TestSettingsAPI:
assert sent_messages[0]["title"].startswith("DMARQ weekly summary") assert sent_messages[0]["title"].startswith("DMARQ weekly summary")
assert "Weekly DMARC summary" in sent_messages[0]["body"] assert "Weekly DMARC summary" in sent_messages[0]["body"]
def test_notification_summary_invalid_period_and_failure_paths(
self,
authed_client: TestClient,
monkeypatch,
):
"""Summary endpoints return useful 400 responses for invalid or failed sends."""
preview_res = authed_client.get("/api/v1/settings/notifications/summary?period=monthly")
assert preview_res.status_code == 400
send_res = authed_client.post("/api/v1/settings/notifications/summary/send?period=monthly")
assert send_res.status_code == 400
def fake_send_summary_notification(db, period): # pylint: disable=unused-argument
return {
"summary": {"period": period},
"notification": {"success": False, "message": "Not delivered."},
}
monkeypatch.setattr(
"app.api.api_v1.endpoints.settings.send_summary_notification",
fake_send_summary_notification,
)
failed_res = authed_client.post("/api/v1/settings/notifications/summary/send?period=daily")
assert failed_res.status_code == 400
assert failed_res.json()["detail"]["notification"]["success"] is False
def test_due_scheduled_summaries_send_once_per_period( def test_due_scheduled_summaries_send_once_per_period(
self, self,
db_session: Session, db_session: Session,
+8 -4
View File
@@ -80,12 +80,14 @@ For database operations, use the [Database Backup and Restore](backups.md) guide
DMARQ stores notification targets in the web settings table. Configure them under DMARQ stores notification targets in the web settings table. Configure them under
**Settings** > **Notifications** and use newline-separated Apprise URLs, such as **Settings** > **Notifications** and use newline-separated Apprise URLs, such as
email, Slack, Teams, Discord, or webhook targets. Saved target URLs are redacted email, Slack, Teams, Discord, or webhook targets. Saved target URLs are redacted
from API responses. from API responses and encrypted at rest with the application `SECRET_KEY`.
| Setting | Description | Default | Example | | Setting | Description | Default | Example |
|---------|-------------|---------|---------| |---------|-------------|---------|---------|
| `notifications.apprise_enabled` | Enable Apprise notification delivery | `false` | `true` | | `notifications.apprise_enabled` | Enable Apprise notification delivery | `false` | `true` |
| `notifications.apprise_urls` | Newline-separated Apprise target URLs | - | `mailto://user:pass@example.com` | | `notifications.apprise_urls` | Newline-separated Apprise target URLs | - | `mailto://user:pass@example.com` |
| `notifications.min_send_interval_minutes` | Minimum minutes between outbound notification deliveries | `15` | `30` |
| `notifications.redact_pii_enabled` | Redact email addresses from outbound notification text | `true` | `true` |
| `notifications.alert_new_sources_enabled` | Alert on newly observed sending sources | `true` | `true` | | `notifications.alert_new_sources_enabled` | Alert on newly observed sending sources | `true` | `true` |
| `notifications.alert_compliance_drop_enabled` | Alert on recent compliance-rate drops | `true` | `true` | | `notifications.alert_compliance_drop_enabled` | Alert on recent compliance-rate drops | `true` | `true` |
| `notifications.alert_compliance_drop_points` | Minimum compliance-rate drop in percentage points | `10` | `15` | | `notifications.alert_compliance_drop_points` | Minimum compliance-rate drop in percentage points | `10` | `15` |
@@ -98,9 +100,11 @@ from API responses.
| `notifications.summary_send_hour_utc` | UTC hour for scheduled summaries | `8` | `7` | | `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` | | `notifications.summary_weekday_utc` | UTC weekday for weekly summaries, where 0 is Monday | `0` | `4` |
Alert history is stored in the database-backed `alert_history` table. Current Alert history is stored in the database-backed `alert_history` table.
retention is indefinite; prune old resolved rows according to your operational Notification and alert-rule configuration changes are stored in
policy if long-term storage size matters. `alert_configuration_audit` with secret values sanitized. Current retention is
indefinite; prune old resolved history and audit rows according to your
operational policy if long-term storage size matters.
### Cloudflare Integration ### Cloudflare Integration
+2 -1
View File
@@ -113,11 +113,12 @@ Goal: notify administrators when action is needed.
Delivered: Delivered:
- Apprise notification integration for newline-separated notification target URLs. - Apprise notification integration for newline-separated notification target URLs.
- 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, stores target URLs encrypted, 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. - Daily and weekly DMARC summary notifications, including scheduled delivery and manual preview/send controls.
- Alert history records active and resolved alerts with first-seen, last-seen, observed-count, and payload metadata. - Alert history records active and resolved alerts with first-seen, last-seen, observed-count, and payload metadata.
- Outbound notifications are rate-limited, email addresses are redacted by default, and notification configuration changes are audited without raw secrets.
Exit criteria: Exit criteria:
- A user can receive meaningful alerts without opening the dashboard daily. - A user can receive meaningful alerts without opening the dashboard daily.
+13 -2
View File
@@ -39,8 +39,13 @@ Configure where DMARQ sends notifications:
4. Add one Apprise target URL per line. 4. Add one Apprise target URL per line.
5. Save and use **Send Test** to verify delivery. 5. Save and use **Send Test** to verify delivery.
Target URLs are redacted after saving so credentials are not exposed through the Target URLs are encrypted in the database and redacted after saving so
settings API or page reloads. credentials are not exposed through the settings API or page reloads.
The notification page also includes a minimum send interval. This cooldown
limits repeated outbound notifications if several alert checks run close
together. Email addresses in notification titles and bodies are redacted by
default before messages are sent.
### Alert Thresholds ### Alert Thresholds
@@ -69,6 +74,12 @@ Alert history appears in **Settings** > **Notifications** after alerts have been
evaluated or sent. Each row shows whether the alert is active or resolved, how evaluated or sent. Each row shows whether the alert is active or resolved, how
many times it has been observed, and the latest alert detail. many times it has been observed, and the latest alert detail.
### Configuration Audit
Notification and alert-rule setting changes appear in **Settings** >
**Notifications**. Secret values are shown only as redacted markers in this
audit trail.
### 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