The irreversible one. The suite no longer stores a credential of any kind.
Removed from app.py: /api/auth/forgot-password, /api/auth/reset-password,
/api/auth/reset-available, /api/auth/password, /api/auth/users/{id}/password,
the four password-bearing input models, the reset throttle and mail body, and
the password arguments to create_user. Removed from auth.py: hash_password,
verify_password, password_problem, MIN_PASSWORD_LEN, _COMMON_PASSWORDS,
create_reset_token, decode_reset_token, RESET_MINUTES, and the bcrypt import.
Removed from notify.py: the password_reset_enabled feature flag. Removed from
manage_users.py: the password prompt and the reset-password command.
token_version STAYS. Password changes no longer exist, but a role change or a
deactivation still has to invalidate sessions that are already issued.
/api/auth/users/{id}/role stays, which is D13 criterion 4 - granting admin to
an existing account must keep working, and it does.
Migration b7e4f1a20c93 uses batch_alter_table because SQLite has no DROP COLUMN
before 3.35 and local dev runs on SQLite while production runs on Postgres.
downgrade() recreates the column NULLABLE rather than NOT NULL as the baseline
declared it: there are no hashes to put back, and a NOT NULL column with no
server default refuses to add itself to a table with rows. The docstring says
plainly that the downgrade does not restore the old login - it exists so the
revision is well-formed, not because stepping back is a recovery path.
Verified:
upgrade head from empty -> password_hash absent from users
downgrade -1 -> column back, nullable (notnull=0)
upgrade head again -> absent again
remaining /api/auth routes -> no password or reset route left
create-admin -> works with no password prompt
grep for the removed symbols -> nothing outside the migration and one
docstring that names the dropped column
NOT verified, and it is a done-when box left open rather than ticked: the
migration has only been round-tripped on SQLite. No Postgres is available here.
batch_alter_table takes the direct ALTER path on Postgres, which is the simpler
of the two, but "simpler" is not "tested".
notify.send_now is now orphaned - its only caller was forgot_password. Logged as
BL-026 rather than deleted in passing, because an immediate unqueued send is a
reasonable primitive to keep and that decision does not belong in an auth task.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
7.1 KiB
Python
181 lines
7.1 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 carry customer CONTEXT — the WP number, its
|
|
title, where the work happens — and a deep link, never customer document CONTENT
|
|
(scope text, descriptions, comments, attachments). Decided 2026-08-20; the link is
|
|
the summary of everything a body leaves out.
|
|
"""
|
|
import os
|
|
import smtplib
|
|
import socket
|
|
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
|
|
# Feature flags (admin console). BIM/VDC is off until it's ready for the field:
|
|
# with it off, the SOP creator hides the BIM section entirely and every SOP is
|
|
# install-only, so no project can be put on the BIM path by accident.
|
|
"bim_enabled": False,
|
|
# Localization defaults for dates, times and numbers. Empty = use each
|
|
# browser's own locale / timezone. A user's own preference wins over these.
|
|
"default_locale": "", # BCP47, e.g. en-US
|
|
"default_timezone": "", # IANA, e.g. America/Chicago
|
|
}
|
|
|
|
|
|
# Settings the app needs before anyone is signed in, or that carry no secrets and
|
|
# are safe for any authenticated user to read (feature flags + localization
|
|
# defaults). Self-service password reset is gone with D13 — the login page links to
|
|
# Okta instead, so there is nothing left for the client to feature-detect.
|
|
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
|
|
|
|
|
|
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 app_flags(db: Session) -> dict:
|
|
"""Feature flags for any signed-in user (no secrets, no SMTP detail)."""
|
|
s = get_settings(db)
|
|
return {k: s.get(k) for k in PUBLIC_KEYS}
|
|
|
|
|
|
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", "")
|
|
# local_hostname pins the EHLO name. Without it smtplib calls getfqdn() on
|
|
# EVERY connect, and that reverse-DNS lookup stalls ~5s per send whenever DNS
|
|
# is slow or unreachable - sends are sequential background tasks, so a batch
|
|
# of notifications trickled out one per five seconds. gethostname() never
|
|
# touches the network. Found 2026-08-20 when the office link dropped.
|
|
with smtplib.SMTP(host, port, timeout=15,
|
|
local_hostname=(socket.gethostname() or "wp-suite")) as srv:
|
|
if s.get("smtp_use_tls", True):
|
|
srv.starttls()
|
|
if user:
|
|
srv.login(user, pw)
|
|
srv.send_message(msg)
|
|
|
|
|
|
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
|
|
"""Send one email immediately, outside the outbox. Used for password resets —
|
|
a reset link must never sit in a queue, and it must not be persisted in the
|
|
notifications table where an admin could read it and take over the account.
|
|
Returns True if it went out."""
|
|
s = get_settings(db)
|
|
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
|
|
return False
|
|
try:
|
|
send_email(s, to_addr, subject, body)
|
|
return True
|
|
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
|
|
log.warning("password-reset email to %s failed: %s", to_addr, e)
|
|
return False
|
|
|
|
|
|
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()
|