Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email
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>
This commit is contained in:
134
server/notify.py
Normal file
134
server/notify.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user