feat(m5c): onboarding — DB invites, request-access, admin approval, email
Move the access whitelist from the ALLOWED_EMAILS env var into a DB Invite table (env kept as bootstrap fallback), and add a self-service request + admin approval flow with fail-soft email. - models: Invite(email, status pending|approved|denied, requested_at, decided_*) - migration 0008: invites table; seed env ALLOWED_EMAILS u ADMIN_EMAILS as approved - auth: is_allowed() (DB-first, env fallback); a denied Google login records a pending request and bounces to /?access=requested instead of a raw 403; public POST /auth/request-access; upsert is idempotent so repeats don't re-spam admins - routes/admin.py (admin-only): list/approve/deny invites + manual add - email.py: smtplib + Gmail App Password, fail-soft (skips if SMTP unset) - /api/me exposes pending_invites; config + .env.example gain SMTP_* - UI: Login 'Request access' form + access=requested/denied handling; Settings -> Access requests (approve/deny + add); admin nudge toast on pending requests Verified locally: request-access creates a pending invite and emails the admin; seed approved npeter83; guinea-pig yt.trash2023 denied until approved.
This commit is contained in:
parent
d6ca4ccd4e
commit
49ab652692
13 changed files with 605 additions and 15 deletions
60
backend/app/email.py
Normal file
60
backend/app/email.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Outbound email for onboarding (access requests + approval notices).
|
||||
|
||||
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.
|
||||
"""
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.config import settings
|
||||
|
||||
log = logging.getLogger("subfeed.email")
|
||||
|
||||
|
||||
def email_enabled() -> bool:
|
||||
return bool(settings.smtp_host and settings.smtp_user and settings.smtp_password)
|
||||
|
||||
|
||||
def _send(to: list[str], subject: str, body: str) -> bool:
|
||||
recipients = [e for e in to if e]
|
||||
if not recipients:
|
||||
return False
|
||||
if not email_enabled():
|
||||
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
|
||||
return False
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.smtp_from or settings.smtp_user
|
||||
msg["To"] = ", ".join(recipients)
|
||||
msg.set_content(body)
|
||||
try:
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) as s:
|
||||
s.starttls(context=ssl.create_default_context())
|
||||
s.login(settings.smtp_user, settings.smtp_password)
|
||||
s.send_message(msg)
|
||||
log.info("Sent email %r to %s", subject, recipients)
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("SMTP send failed (%r to %s)", subject, recipients)
|
||||
return False
|
||||
|
||||
|
||||
def send_access_approved(email: str) -> bool:
|
||||
return _send(
|
||||
[email],
|
||||
"Your Subfeed access is approved",
|
||||
"Good news — your access to Subfeed has been approved.\n\n"
|
||||
"Sign in with this Google account to start browsing your subscriptions.\n",
|
||||
)
|
||||
|
||||
|
||||
def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
||||
return _send(
|
||||
admins,
|
||||
"Subfeed: new access request",
|
||||
f"{requester} has requested access to Subfeed.\n\n"
|
||||
"Approve or deny it in Settings → Admin.\n",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue