Files
rfc-app/backend/app/email_invite.py
Ben Stull 1456c8b73f Release 0.17.0: admin-create user + invite email (+ #21 Part C Amplitude wiring)
Wave 5. Roadmap item #16. Folds in #21 Part C Amplitude wiring inline.

From the v0.9.0 /admin/users surface, an admin can create a fresh
users row, assign a role (admin/owner/granted-beta-user), include
an optional custom message, and dispatch an invite email carrying
a single-use opaque claim token (256-bit CSPRNG, bcrypt-at-rest,
7-day TTL). The invitee clicks through, lands at /invites/claim,
optionally checks "trust this device for 30 days", and is signed
in without going through OTC (the token in the email is itself
proof of email control).

Backend: migration 019_user_invite_tokens.sql (auto-applied);
backend/app/invites.py (create + claim + list module);
backend/app/email_invite.py (sibling of email_otc); POST
/api/admin/users + GET /api/admin/users/invites in api_admin;
POST /api/invites/claim in main.py's oauth_router (shares
trust-device cookie helper with /auth/otc/verify). 15 new tests in
test_admin_create_user_invite_vertical.py. permission_events row
with event_kind='user_invited' for the audit trail.

Frontend: Admin.jsx Create-user-invite modal + (pending invite)
badge in UserRow; InviteClaim.jsx /invites/claim landing page.
App.jsx route registration. api.js helpers.

Design choices documented in the CHANGELOG: immediate-send (no
admin-review queue); no bulk-invite (deferred); OTC skipped on
first sign-in (token = proof of email control); no users table
changes (discriminator is active user_invite_tokens row, not a
new first_sign_in_at column); refusal cases (self-invite 422,
duplicate email 409, non-owner admin granting owner 422).

Amplitude wiring (inline, #21 Part C):
  - USER_INVITED from CreateUserInviteModal { target_user_id,
    initial_role, custom_message_chars }
  - INVITE_CLAIMED from InviteClaim { invited_by_admin_id,
    initial_role, needs_passcode, trust_device }
  - identify() BEFORE the claim event with properties:
    claim_method 'admin-invite', invited_at (setOnce),
    invited_by_admin_id (setOnce), initial_role (setOnce) —
    so the Amplitude user record is created with the OHM
    user_id from the very first event the invitee fires

Subagent ο shipped the feature on feature/v0.17.0-admin-create-user
(41b0c6a). Driver-side integration squash-merged into main,
hand-resolved 5 files (VERSION, package.json, CHANGELOG —
strict-descending to 0.17.0 → 0.16.0 → 0.15.0; App.jsx — both
new routes kept; api_admin.py — both per-user additive fields
+ pending-invite query both kept). Added inline Amplitude wiring
in CreateUserInviteModal + InviteClaim. 252 backend tests pass
(33 new across #12 + #16 surfaces). Frontend build verified green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:10:52 -07:00

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"
)