214 lines
7.6 KiB
Python
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,
|
|
}
|
|
|
|
|