""" Notification service with pluggable adapters (email, webhook). Sends best-effort, non-blocking notifications for domain events such as QDRO status transitions. Failures are logged and never raise. """ from __future__ import annotations import json import hmac import hashlib import smtplib from email.message import EmailMessage from typing import Any, Dict, Iterable, List, Optional import httpx from app.config import settings from sqlalchemy.orm import Session from app.models.lookups import SystemSetup class NotificationAdapter: def send(self, event_type: str, payload: Dict[str, Any]) -> None: raise NotImplementedError class EmailAdapter(NotificationAdapter): def __init__( self, *, smtp_host: str, smtp_port: int = 587, username: Optional[str] = None, password: Optional[str] = None, starttls: bool = True, send_from: str = "no-reply@localhost", default_recipients: Optional[List[str]] = None, ) -> None: self.smtp_host = smtp_host self.smtp_port = smtp_port self.username = username self.password = password self.starttls = starttls self.send_from = send_from self.default_recipients = default_recipients or [] def send(self, event_type: str, payload: Dict[str, Any]) -> None: # pragma: no cover - exercised via service override = bool(payload.get("__notify_override")) recipients: List[str] = [] if override else list(self.default_recipients) # Allow payload to specify recipients override extra_to = payload.get("__notify_to") if isinstance(extra_to, str): recipients.extend([addr.strip() for addr in extra_to.split(",") if addr.strip()]) elif isinstance(extra_to, list): recipients.extend([str(x).strip() for x in extra_to if str(x).strip()]) if not recipients: return subject = f"{event_type}" body = json.dumps(payload, default=str, indent=2) msg = EmailMessage() msg["From"] = self.send_from msg["To"] = ", ".join(recipients) msg["Subject"] = subject msg.set_content(body) with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10) as smtp: if self.starttls: try: smtp.starttls() except Exception: # Continue without TLS if not supported pass if self.username and self.password: smtp.login(self.username, self.password) smtp.send_message(msg) class WebhookAdapter(NotificationAdapter): def __init__(self, *, url: str, secret: Optional[str] = None, timeout: float = 5.0) -> None: self.url = url self.secret = secret self.timeout = timeout def _signature(self, body: bytes) -> Optional[str]: if not self.secret: return None sig = hmac.new(self.secret.encode("utf-8"), body, hashlib.sha256).hexdigest() return f"sha256={sig}" def send(self, event_type: str, payload: Dict[str, Any]) -> None: # pragma: no cover - exercised via service body = json.dumps({"type": event_type, "payload": payload}, default=str).encode("utf-8") headers = {"Content-Type": "application/json"} sig = self._signature(body) if sig: headers["X-Signature"] = sig try: with httpx.Client(timeout=self.timeout) as client: client.post(self.url, content=body, headers=headers) except Exception: # Swallow errors by design pass class NotificationService: def __init__(self, adapters: Iterable[NotificationAdapter]) -> None: self.adapters = list(adapters) def emit(self, event_type: str, payload: Dict[str, Any]) -> None: for adapter in self.adapters: try: # If a per-event webhook override is present and this is a default webhook adapter # and override flag is set, skip default webhook send if isinstance(adapter, WebhookAdapter) and payload.get("__webhook_url") and payload.get("__webhook_override"): continue adapter.send(event_type, payload) except Exception: # Never block or raise from notification adapters continue # If explicit webhook override is provided, send a one-off webhook request if payload.get("__webhook_url"): try: WebhookAdapter( url=str(payload.get("__webhook_url")), secret=str(payload.get("__webhook_secret")) if payload.get("__webhook_secret") else None, ).send(event_type, payload) except Exception: pass def build_default_notification_service() -> NotificationService: if not settings.notifications_enabled: return NotificationService([]) adapters: List[NotificationAdapter] = [] # Email adapter if SMTP host is configured if settings.smtp_host: default_to: List[str] = [] if settings.qdro_notify_email_to: default_to = [addr.strip() for addr in settings.qdro_notify_email_to.split(",") if addr.strip()] email_adapter = EmailAdapter( smtp_host=settings.smtp_host, smtp_port=settings.smtp_port, username=settings.smtp_username, password=settings.smtp_password, starttls=settings.smtp_starttls, send_from=settings.notification_email_from, default_recipients=default_to, ) adapters.append(email_adapter) # Webhook adapter if URL is configured if settings.qdro_notify_webhook_url: adapters.append( WebhookAdapter( url=settings.qdro_notify_webhook_url, secret=settings.qdro_notify_webhook_secret, ) ) return NotificationService(adapters) # Singleton for app code to import notification_service = build_default_notification_service() def _get_setting(db: Session, key: str) -> Optional[str]: row = db.query(SystemSetup).filter(SystemSetup.setting_key == key).first() return row.setting_value if row else None def resolve_qdro_routes( db: Session, *, file_no: Optional[str], plan_id: Optional[str], ) -> Dict[str, Optional[Any]]: """ Resolve per-file or per-plan routing from SystemSetup. Precedence: file-specific overrides win over plan-specific, which win over defaults. Returns a dict with keys: email_to (comma-separated string), webhook_url, webhook_secret. """ email_to: Optional[str] = None webhook_url: Optional[str] = None webhook_secret: Optional[str] = None # File overrides if file_no: email_to = _get_setting(db, f"notifications.qdro.email.to.file.{file_no}") or email_to webhook_url = _get_setting(db, f"notifications.qdro.webhook.url.file.{file_no}") or webhook_url webhook_secret = _get_setting(db, f"notifications.qdro.webhook.secret.file.{file_no}") or webhook_secret # Plan overrides (only if not set by file) if plan_id: if email_to is None: email_to = _get_setting(db, f"notifications.qdro.email.to.plan.{plan_id}") or email_to if webhook_url is None: webhook_url = _get_setting(db, f"notifications.qdro.webhook.url.plan.{plan_id}") or webhook_url if webhook_secret is None: webhook_secret = _get_setting(db, f"notifications.qdro.webhook.secret.plan.{plan_id}") or webhook_secret return { "email_to": email_to, "webhook_url": webhook_url, "webhook_secret": webhook_secret, }