| """Notifier interface + console (default) and SMTP backends (spec §10). |
| |
| Console backend writes to stdout/log and persists a notifications row; real SMTP only when |
| env-configured. SMS is an off-by-default adapter (paid + network — do not enable without |
| confirmation). |
| """ |
| from __future__ import annotations |
|
|
| import logging |
| import smtplib |
| from abc import ABC, abstractmethod |
| from datetime import datetime, timezone |
| from email.message import EmailMessage |
|
|
| from sqlalchemy.orm import Session |
|
|
| from ..config import settings |
| from ..models import Notification |
| from ..models.base import NotificationChannel, NotificationStatus |
|
|
| logger = logging.getLogger("pawtrace.notify") |
|
|
|
|
| class Notifier(ABC): |
| @abstractmethod |
| def send(self, *, to: str, subject: str, body: str) -> bool: |
| """Deliver a message; return True on success.""" |
|
|
|
|
| class ConsoleNotifier(Notifier): |
| def send(self, *, to: str, subject: str, body: str) -> bool: |
| logger.info("EMAIL -> %s | %s\n%s", to, subject, body) |
| print(f"\n[EMAIL] to={to} subject={subject!r}\n{body}\n") |
| return True |
|
|
|
|
| class SMTPNotifier(Notifier): |
| def send(self, *, to: str, subject: str, body: str) -> bool: |
| msg = EmailMessage() |
| msg["From"] = settings.smtp_from |
| msg["To"] = to |
| msg["Subject"] = subject |
| msg.set_content(body) |
| try: |
| with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as s: |
| if settings.smtp_user: |
| s.starttls() |
| s.login(settings.smtp_user, settings.smtp_password) |
| s.send_message(msg) |
| return True |
| except Exception: |
| logger.exception("SMTP send failed") |
| return False |
|
|
|
|
| def get_notifier() -> Notifier: |
| if settings.notifier == "smtp" and settings.smtp_host: |
| return SMTPNotifier() |
| return ConsoleNotifier() |
|
|
|
|
| def send_notification( |
| db: Session, |
| *, |
| user_id: int | None, |
| to: str, |
| subject: str, |
| body: str, |
| case_id: int | None = None, |
| match_id: int | None = None, |
| ) -> Notification: |
| """Send via the configured Notifier and persist a notifications row (spec §7.8).""" |
| notif = Notification( |
| user_id=user_id, |
| case_id=case_id, |
| match_id=match_id, |
| channel=NotificationChannel.email, |
| to_address=to, |
| payload=f"{subject}\n\n{body}", |
| status=NotificationStatus.queued, |
| ) |
| db.add(notif) |
| db.flush() |
|
|
| ok = get_notifier().send(to=to, subject=subject, body=body) |
| notif.status = NotificationStatus.sent if ok else NotificationStatus.failed |
| if ok: |
| notif.sent_at = datetime.now(timezone.utc) |
| db.flush() |
| return notif |
|
|