"""Notifications: admin-configurable email + an outbox. Email notifications are OFF by default and controlled from the admin console (a toggle stored in `app_settings`). Even when enabled, mail is only sent if SMTP is configured. The SMTP PASSWORD is read from the `SMTP_PASSWORD` environment variable and is NEVER stored in the database or shown in the UI. Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app record — and, when email is on + SMTP is set, the row is delivered by email in a background task. Notification bodies deliberately avoid customer IP: they carry a WP number and a deep link, not the work-package contents. """ import os import smtplib import uuid import logging from email.message import EmailMessage from typing import Optional from sqlalchemy.orm import Session from . import models log = logging.getLogger("wpsuite.notify") SETTINGS_KEY = "notifications" DEFAULTS = { "email_enabled": False, # master toggle — OFF until SMTP is sorted "smtp_host": "", "smtp_port": 587, "smtp_use_tls": True, "smtp_username": "", "from_addr": "", "from_name": "Work Package Suite", "app_base_url": "", # e.g. https://wp.controls.dev — used to build email links } def get_settings(db: Session) -> dict: row = db.get(models.AppSetting, SETTINGS_KEY) s = dict(DEFAULTS) if row and row.value: s.update({k: row.value[k] for k in row.value if k in DEFAULTS}) return s def save_settings(db: Session, patch: dict) -> dict: cur = get_settings(db) for k in DEFAULTS: if k in patch and patch[k] is not None: cur[k] = patch[k] row = db.get(models.AppSetting, SETTINGS_KEY) if row: row.value = cur else: db.add(models.AppSetting(key=SETTINGS_KEY, value=cur)) db.commit() return cur def public_settings(db: Session) -> dict: """Settings safe to return to the admin UI — no secrets.""" s = get_settings(db) s["smtp_password_set"] = bool(os.getenv("SMTP_PASSWORD")) return s def smtp_ready(s: dict) -> bool: return bool(s.get("smtp_host") and s.get("from_addr")) def send_email(s: dict, to_addr: str, subject: str, body: str) -> None: """Send one email via SMTP. Raises on any failure (caller records it).""" if not to_addr: raise ValueError("no recipient email") msg = EmailMessage() from_name = s.get("from_name") or "" msg["From"] = f"{from_name} <{s['from_addr']}>" if from_name else s["from_addr"] msg["To"] = to_addr msg["Subject"] = subject msg.set_content(body) host = s["smtp_host"] port = int(s.get("smtp_port") or 587) user = s.get("smtp_username") or "" pw = os.getenv("SMTP_PASSWORD", "") with smtplib.SMTP(host, port, timeout=15) as srv: if s.get("smtp_use_tls", True): srv.starttls() if user: srv.login(user, pw) srv.send_message(msg) def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str, link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification": """Record a notification. Marked 'pending' only if email is enabled + SMTP ready + the recipient has an email; otherwise 'skipped' (still an in-app record). Does NOT commit — the caller commits with its own transaction. Returns the row.""" s = get_settings(db) deliverable = bool(s.get("email_enabled")) and smtp_ready(s) and bool(user.email) n = models.Notification( id="ntf_" + uuid.uuid4().hex[:12], user_id=user.id, email=user.email or "", kind=kind, wp_id=wp_id, project_id=project_id, subject=subject[:300], body=body, link=link[:500], status="pending" if deliverable else "skipped", ) db.add(n) return n def deliver(notif_id: str) -> None: """Background task: send one pending notification, on its own DB session.""" from .db import SessionLocal db = SessionLocal() try: n = db.get(models.Notification, notif_id) if not n or n.status != "pending": return s = get_settings(db) if not (s.get("email_enabled") and smtp_ready(s) and n.email): n.status = "skipped" db.commit() return try: send_email(s, n.email, n.subject, n.body) n.status = "sent" n.sent_at = models.utcnow() except Exception as e: # noqa: BLE001 — record any SMTP failure, don't crash the worker n.status = "failed" n.error = str(e)[:400] log.warning("notification %s failed to send: %s", notif_id, e) db.commit() finally: db.close()