Brings the Work Package Suite from a browser-local prototype to a multi-tenant, SQL-backed deployment hardened for customer IP. Auth & access control - Local username/password login (bcrypt + JWT in an HttpOnly cookie), admin-managed users, per-project membership, and project-scoped API access. - Admin console: change user roles, view the audit trail, manage settings. Security hardening - CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping. - Login lockout, token_version session revocation, stronger password policy, fail-closed secret loading, encrypted (AES-256) database backups. Persistence & schema - SOPs and Work Packages are now DB-backed and shared across users, written through a durable client sync outbox that queues offline edits. - Alembic migrations applied automatically on container start. New capabilities - Phase 2 dashboard (progress, gating, pagination, archive). - Phase 3 PWA "Field View" with offline caching and auth fallback. - WP owner assignment with OPTIONAL email notifications, OFF by default and toggled from the admin console. SMTP password is read only from the SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link, never customer IP. Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
135 lines
4.6 KiB
Python
135 lines
4.6 KiB
Python
"""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()
|