diff --git a/backend/app/api/api_v1/endpoints/settings.py b/backend/app/api/api_v1/endpoints/settings.py index df26c4f..785b3f5 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.notifications import send_notification router = APIRouter() logger = logging.getLogger(__name__) @@ -113,66 +114,25 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [ }, # ── Notifications ───────────────────────────────────────────────────────── { - "key": "notifications.email_enabled", + "key": "notifications.apprise_enabled", "value": "false", - "description": "Send email notifications when new DMARC failures are detected", + "description": "Send notifications through configured Apprise target URLs", "value_type": "boolean", "category": "notifications", }, { - "key": "notifications.email_from", + "key": "notifications.apprise_urls", "value": "", - "description": "From address used for notification emails", + "description": "Newline-separated Apprise notification target URLs", "value_type": "string", "category": "notifications", }, - { - "key": "notifications.email_to", - "value": "", - "description": "Comma-separated list of recipient addresses for notifications", - "value_type": "string", - "category": "notifications", - }, - { - "key": "notifications.smtp_host", - "value": "", - "description": "SMTP server hostname for sending notification emails", - "value_type": "string", - "category": "notifications", - }, - { - "key": "notifications.smtp_port", - "value": "587", - "description": "SMTP server port", - "value_type": "integer", - "category": "notifications", - }, - { - "key": "notifications.smtp_username", - "value": "", - "description": "SMTP authentication username", - "value_type": "string", - "category": "notifications", - }, - { - "key": "notifications.smtp_password", - "value": "", - "description": "SMTP authentication password", - "value_type": "string", - "category": "notifications", - }, - { - "key": "notifications.smtp_use_tls", - "value": "true", - "description": "Use TLS when connecting to the SMTP server", - "value_type": "boolean", - "category": "notifications", - }, ] # Keys whose values should be redacted in GET responses (treated as secrets) _SECRET_KEYS = { "cloudflare.api_token", + "notifications.apprise_urls", "notifications.smtp_password", } @@ -245,6 +205,17 @@ class SettingResponse(BaseModel): updated_at: Optional[str] +class NotificationTestResponse(BaseModel): + """Sanitized response from a test notification send.""" + + success: bool + message: str + configured_targets: int = 0 + invalid_targets: int = 0 + skipped: bool = False + error: Optional[str] = None + + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @@ -269,6 +240,27 @@ async def list_settings( return [_row_to_dict(row) for row in rows] +@router.post("/notifications/test", response_model=NotificationTestResponse) +async def test_notification_settings( + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> NotificationTestResponse: + """Send a test notification using the configured Apprise targets.""" + _seed_defaults(db) + result = send_notification( + db, + title="DMARQ test notification", + body="This confirms that DMARQ can reach the configured notification target.", + force=True, + ) + if not result.success: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=result.to_dict(), + ) + return result.to_dict() + + @router.get("/{key:path}", response_model=SettingResponse) async def get_setting( key: str, diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py new file mode 100644 index 0000000..0e39c19 --- /dev/null +++ b/backend/app/services/notifications.py @@ -0,0 +1,127 @@ +"""Notification delivery helpers backed by Apprise.""" + +from __future__ import annotations + +import logging +from dataclasses import asdict, dataclass +from typing import Dict, List, Optional, Tuple + +import apprise +from sqlalchemy.orm import Session + +from app.models.setting import Setting + +logger = logging.getLogger(__name__) + + +@dataclass +class NotificationResult: + """Sanitized result for a notification send attempt.""" + + success: bool + message: str + configured_targets: int = 0 + invalid_targets: int = 0 + skipped: bool = False + error: Optional[str] = None + + def to_dict(self) -> Dict[str, object]: + return asdict(self) + + +def _truthy(value: Optional[str]) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _split_apprise_urls(value: Optional[str]) -> List[str]: + if not value: + return [] + return [ + line.strip() + for line in value.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + +def _notification_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 _add_apprise_targets(notifier: apprise.Apprise, urls: List[str]) -> Tuple[int, int]: + configured_targets = 0 + invalid_targets = 0 + for url in urls: + try: + if notifier.add(url): + configured_targets += 1 + else: + invalid_targets += 1 + except Exception: # pylint: disable=broad-exception-caught + invalid_targets += 1 + logger.warning("Invalid Apprise notification target was ignored.") + return configured_targets, invalid_targets + + +def send_notification( + db: Session, + *, + title: str, + body: str, + force: bool = False, +) -> NotificationResult: + """Send a notification through configured Apprise target URLs.""" + settings = _notification_settings(db) + enabled = _truthy(settings.get("notifications.apprise_enabled")) + + if not enabled and not force: + return NotificationResult( + success=False, + skipped=True, + message="Notifications are disabled.", + ) + + urls = _split_apprise_urls(settings.get("notifications.apprise_urls")) + if not urls: + return NotificationResult( + success=False, + message="No notification targets are configured.", + ) + + notifier = apprise.Apprise() + configured_targets, invalid_targets = _add_apprise_targets(notifier, urls) + + if configured_targets == 0: + return NotificationResult( + success=False, + message="No valid notification targets are configured.", + invalid_targets=invalid_targets, + ) + + try: + success = bool(notifier.notify(title=title, body=body)) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Apprise notification delivery failed.") + return NotificationResult( + success=False, + message="Notification delivery failed.", + configured_targets=configured_targets, + invalid_targets=invalid_targets, + error="delivery_failed", + ) + + if not success: + return NotificationResult( + success=False, + message="Notification delivery was not accepted by any configured target.", + configured_targets=configured_targets, + invalid_targets=invalid_targets, + error="not_delivered", + ) + + return NotificationResult( + success=True, + message="Notification sent.", + configured_targets=configured_targets, + invalid_targets=invalid_targets, + ) diff --git a/backend/app/templates/settings.html b/backend/app/templates/settings.html index 3b41bf6..2b3e1f8 100644 --- a/backend/app/templates/settings.html +++ b/backend/app/templates/settings.html @@ -204,97 +204,49 @@ {% endcall %} {% endcall %} - + {% call card() %} {% call card_header() %} - {% call card_title() %}Email Notifications{% endcall %} - {% call card_description() %}Send alerts when DMARC failures are detected{% endcall %} + {% call card_title() %}Notifications{% endcall %} + {% call card_description() %}Send alerts through Apprise-compatible targets{% endcall %} {% endcall %} {% call card_content() %}
-