"""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.utils import formataddr from .email import EmailConfig, _SENT, record_outbound from .email_envelope import build_envelope 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) # v0.18.0: invite mail carries a `List-Unsubscribe: ` # only (no signed URL) — the invitee isn't a user yet, so there # is no per-user opt-out row to flip. The operator handles # ad-hoc opt-outs from the mailto: target. Per the proposal's # "Tradeoff discussion": the invite was unsolicited from the # recipient's perspective, so the courtesy header is right; # but it can't be a one-click URL because the row doesn't # exist yet. msg = build_envelope( to_address=to_address, from_address=cfg.from_address, from_name=cfg.from_name, subject=subject, body_plain=body, unsubscribe_mailto=cfg.unsubscribe_mailto, ) envelope = { "to": to_address, "from": formataddr((cfg.from_name, cfg.from_address)), "subject": subject, "body": body, "kind": "invite", "message": msg, } _SENT.append(envelope) message_id = msg["Message-ID"] if not cfg.enabled: log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address) record_outbound( to_address=to_address, from_address=cfg.from_address, subject=subject, kind="invite", status="deferred", message_id=message_id, ) 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) record_outbound( to_address=to_address, from_address=cfg.from_address, subject=subject, kind="invite", status="deferred", message_id=message_id, ) return True try: 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() record_outbound( to_address=to_address, from_address=cfg.from_address, subject=subject, kind="invite", status="sent", message_id=message_id, ) return True except Exception as exc: log.exception("invite email send failed: to=%s", to_address) record_outbound( to_address=to_address, from_address=cfg.from_address, subject=subject, kind="invite", status="failed", error=f"{type(exc).__name__}: {exc}", message_id=message_id, ) 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" )