feat(admin): delete a whitelist entry, and stop hiding a broken mail setup

Three operator gaps, all found the same way — a person could not sign up and the
app showed nobody anything.

- DELETE /api/admin/invites/{id} with a delete control on both the pending
  requests and the previously-decided list. Deny keeps the row so the person
  cannot immediately re-ask; delete forgets the address. Refused with 409 while
  a User row exists for it, because is_allowed reads that row and removing it
  would revoke a live user's access with no other sign — the list ships
  has_account so the UI withholds the button instead of offering one that fails.
- Email is fail-soft by design, which had come to mean invisible. _send() now
  records an email_undelivered notification for every admin when a message is
  skipped or rejected, naming the message that was lost, throttled to one an
  hour. /api/me carries email_configured, driving a standing warning on
  Configuration → Email / SMTP and above the access-request list.
- The sign-in card's Create account / Forgot password / Didn't get the email
  views are history entries (useHistorySubview), so Back returns to the previous
  view instead of only moving the address bar.

Also: the per-test rate-limiter reset sweeps every loaded app.* module rather
than a hand-kept list of three — this change added a limiter outside it.
This commit is contained in:
2026-09-01 23:09:25 +02:00
parent 24c93cbb83
commit 44f626f732
25 changed files with 636 additions and 23 deletions
+55
View File
@@ -3,6 +3,11 @@
Gmail SMTP + App Password by default. Deliberately fail-soft: if SMTP isn't configured
or a send errors, we log and return False so the caller can fall back to in-app
notifications — email is never load-bearing for the app's core flow.
Fail-soft must not mean invisible, though: every drop also leaves an in-app notice in the admins'
inbox (see `_alert_admins`). An instance whose SMTP was never filled in silently swallows
verification links, password resets and access-request notices, and the operator's first clue used
to be a person telling them nothing arrived.
"""
import logging
@@ -11,12 +16,60 @@ import ssl
from email.message import EmailMessage
from email.utils import formatdate
from sqlalchemy import select
from app import sysconfig
from app.config import settings
from app.db import SessionLocal
from app.notifications import create_notification
from app.ratelimit import RateLimiter
log = logging.getLogger("siftlode.email")
# One admin notice an hour per reason. A relay that is down fails every send, and the inbox must
# stay readable; the log keeps the full record either way.
_alert_throttle = RateLimiter(max_events=1, window_seconds=3600)
def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
"""Record an in-app notice that a message did NOT go out. Best effort in every direction: it
opens its own session (callers are often background tasks), swallows its own errors, and never
reports failure to the caller — this is the fallback path, it must not become a new way to
break a send."""
if not _alert_throttle.allow(reason):
return
try:
from app.models import User # local: app.models imports settle before this ever runs
with SessionLocal() as db:
admins = (
db.execute(
select(User.id).where(
User.role == "admin",
User.is_active.is_(True),
User.is_suspended.is_(False),
)
)
.scalars()
.all()
)
if not admins:
return
for uid in admins:
create_notification(
db,
user_id=uid,
type="email_undelivered",
# English fallback; the inbox renders a translated version from `data`.
title="Email could not be sent",
body=subject,
data={"reason": reason, "subject": subject, "recipients": recipients},
commit=False,
)
db.commit()
except Exception:
log.exception("Could not record the undelivered-email notice (%r)", subject)
def _smtp() -> dict:
"""Effective SMTP config (admin DB override over env defaults). Opens its own short
@@ -47,6 +100,7 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
c = _smtp()
if not (c["host"] and c["user"] and c["password"]):
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
_alert_admins("not_configured", subject, recipients)
return False
msg = EmailMessage()
msg["Subject"] = subject
@@ -67,6 +121,7 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
return True
except Exception:
log.exception("SMTP send failed (%r to %s)", subject, recipients)
_alert_admins("send_failed", subject, recipients)
return False