Files
delphi-database/app/services/notification.py
2025-08-15 17:19:51 -05:00

214 lines
7.6 KiB
Python

"""
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,
}