41b0c6af99
Roadmap item #16 / §6.1. From the v0.9.0 /admin/users surface, an admin can now create a user record before that person has ever signed in — typing first name, last name, email, role, and an optional custom message — and the framework sends an invite email carrying a single-use claim link. The invitee clicks through to /invites/claim?token=…, the token is consumed, the session is established, and the user is routed to the passcode-set screen on first sign-in. New endpoints: * POST /api/admin/users — admin-only; provisions the users row + user_invite_tokens row + sends the invite email + writes a permission_events row with event_kind='user_invited'. * GET /api/admin/users/invites — admin-only; lists active (not-claimed, not-expired) invites with the issuing admin. * POST /api/invites/claim — anonymous-reachable; validates the token, consumes the row, signs the invitee in (skipping OTC per the roadmap — clicking the email link is itself proof of email control), returns needs_passcode for the frontend's route-onward decision. Schema: migration slot 019 — user_invite_tokens (id, email, role, first/last name, custom_message, bcrypt token_hash, expires_at, created_at, created_by_admin_id, claimed_at, claimed_by_user_id, invited_user_id). Slot 018 reserved for the parallel #12 release (per-RFC invitation) shipping in the same wave; distinct table (rfc_invitations there vs. user_invite_tokens here) so they coexist cleanly. No users-table changes — the brief floated a NULL-column discriminator for "(pending invite)" but the existing users.last_seen_at is NOT NULL with a datetime('now') default, so the discriminator is the active user_invite_tokens row joined on invited_user_id; the admin user-listing carries a pending_invite field populated via that join. Claim route: frontend /invites/claim?token=… (new InviteClaim.jsx). Anonymous-reachable; renders "Claim my account" CTA with an optional v0.11.0-style "trust this device" checkbox, calls the claim endpoint, routes onward. Token shape: opaque DB token (256 bits CSPRNG via secrets.token_urlsafe(32), bcrypt-at-rest), not JWT. Opaque chosen because admin revocation is then a single SQL UPDATE — JWT would be stateless but harder to invalidate. Open-question decisions: immediate-send (no admin-review-then- send queue; future enhancement), no bulk-invite (deferred to follow-up; v0.17.0 is one-at-a-time), 7-day expiry as a constant (INVITE_TOKEN_TTL_DAYS in backend/app/invites.py; env-var configurability is a §19.2 candidate), OTC skipped on first sign-in (the token in the email is itself proof of email control; subsequent sign-ins go through OTC / passcode unchanged). Refusals on POST /api/admin/users: * 422 self-invite (use the role-change channel for self-edits) * 409 duplicate email (use the existing grant/role gestures) * 422 owner-grant by non-owner admin (§6.1 owner-zero is the only owner bootstrap path) * 422 pydantic — malformed email / unknown role / custom_message > 500 chars * 403 non-admin caller / 401 anonymous 15 new backend tests in test_admin_create_user_invite_vertical.py (happy path, all four refusals, claim with valid / expired / already-claimed / unknown token, pending-invite badge before and after claim, listing admin-only). 234 total backend tests pass; frontend build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
137 lines
5.1 KiB
Python
137 lines
5.1 KiB
Python
"""Outbound admin-invite email — a thin wrapper over the existing SMTP layer.
|
|
|
|
v0.17.0 / roadmap item #16: when an admin uses `POST /api/admin/users` to
|
|
create-with-invite, this module composes and sends the invite envelope.
|
|
|
|
Structurally distinct from:
|
|
|
|
* `email_otc.py` (v0.7.0) — that one carries a credential the user
|
|
just requested; this one carries a credential the admin is sending
|
|
unsolicited.
|
|
* `email.py` (§15.4 notification mailer) — that one is inbox-driven,
|
|
bundled, with category opt-outs; this one is a single transactional
|
|
outbound to a person who does not yet have an inbox.
|
|
* v0.9.0's `new_beta_request` admin notification — that one is
|
|
invitee-to-admin (an existing pending user asking to be let in);
|
|
this one is admin-to-invitee (an admin reaching out to seed access).
|
|
|
|
So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
|
|
and the From identity, but writes its own envelope. In dev (no
|
|
SMTP_HOST set), the envelope is logged at INFO level and pushed to
|
|
the same `_SENT` buffer the notification mailer uses, so the
|
|
integration tests can assert on the outbound shape without standing
|
|
up an SMTP server.
|
|
|
|
The send is synchronous. The admin endpoint returns 200 on the
|
|
create-row half regardless of send outcome — a transient SMTP
|
|
failure should not roll back the invite (an admin can re-send via a
|
|
future "resend invite" gesture, deferred to a follow-up release).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import smtplib
|
|
from email.message import EmailMessage
|
|
from email.utils import formataddr
|
|
|
|
from .email import EmailConfig, _SENT
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def send_invite_email(
|
|
*,
|
|
to_address: str,
|
|
claim_url: str,
|
|
inviter_display: str,
|
|
inviter_email: str,
|
|
custom_message: str = "",
|
|
) -> bool:
|
|
"""Compose and send the admin-invite email. Returns True on the
|
|
happy path; False on SMTP failure. The notifier-side buffer
|
|
`_SENT` is appended either way so tests can assert on content.
|
|
|
|
The body names the inviting admin, embeds the optional custom
|
|
message in a clearly delimited block if present, and ships the
|
|
claim link. The subject names the inviter so the recipient can
|
|
recognize the sender at a glance in their inbox preview.
|
|
"""
|
|
cfg = EmailConfig.from_env()
|
|
subject = _subject(inviter_display, cfg)
|
|
body = _body(claim_url, inviter_display, inviter_email, custom_message, cfg)
|
|
envelope = {
|
|
"to": to_address,
|
|
"from": formataddr((cfg.from_name, cfg.from_address)),
|
|
"subject": subject,
|
|
"body": body,
|
|
"kind": "invite",
|
|
}
|
|
_SENT.append(envelope)
|
|
|
|
if not cfg.enabled:
|
|
log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
|
return True
|
|
if not cfg.smtp_host:
|
|
# Dev fallback: surface the claim URL at INFO so the operator can
|
|
# complete a claim flow without an SMTP relay. In production
|
|
# SMTP_HOST is always set per OHM's overlay.
|
|
log.info("invite email (stdout fallback): to=%s claim_url=%s", to_address, claim_url)
|
|
return True
|
|
|
|
try:
|
|
msg = EmailMessage()
|
|
msg["From"] = envelope["from"]
|
|
msg["To"] = to_address
|
|
msg["Subject"] = subject
|
|
msg.set_content(body)
|
|
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
|
|
try:
|
|
if cfg.smtp_starttls:
|
|
smtp.starttls()
|
|
if cfg.smtp_user:
|
|
smtp.login(cfg.smtp_user, cfg.smtp_password)
|
|
smtp.send_message(msg)
|
|
finally:
|
|
smtp.quit()
|
|
return True
|
|
except Exception:
|
|
log.exception("invite email send failed: to=%s", to_address)
|
|
return False
|
|
|
|
|
|
def _subject(inviter_display: str, cfg: EmailConfig) -> str:
|
|
"""e.g. "You're invited to Wiggleverse by Ben Stull"."""
|
|
inviter = inviter_display or "an admin"
|
|
return f"You're invited to {cfg.from_name} by {inviter}"
|
|
|
|
|
|
def _body(
|
|
claim_url: str,
|
|
inviter_display: str,
|
|
inviter_email: str,
|
|
custom_message: str,
|
|
cfg: EmailConfig,
|
|
) -> str:
|
|
inviter = inviter_display or "An admin"
|
|
inviter_suffix = f" ({inviter_email})" if inviter_email else ""
|
|
message_block = ""
|
|
if custom_message.strip():
|
|
# Indent the custom message so it reads as a clearly-delimited
|
|
# quote rather than running together with the framework's
|
|
# framing text. Per-line indent keeps multi-line messages
|
|
# visually grouped in plain-text mail clients.
|
|
indented = "\n".join(f" {line}" for line in custom_message.strip().splitlines())
|
|
message_block = f"\nA personal note from {inviter}:\n\n{indented}\n"
|
|
|
|
return (
|
|
f"{inviter}{inviter_suffix} has invited you to {cfg.from_name}.\n"
|
|
f"{message_block}\n"
|
|
f"Click the link below to claim your account and sign in.\n"
|
|
f"This link is single-use and expires in 7 days.\n\n"
|
|
f" {claim_url}\n\n"
|
|
f"If you weren't expecting this invitation, you can ignore this\n"
|
|
f"email — no account becomes active until you click the link.\n\n"
|
|
f"---\n"
|
|
f"{cfg.from_name} · {cfg.app_url}\n"
|
|
)
|