a51beecbc9
Lands roadmap item #12: the RFC's owner can invite specific users by email to one of two per-RFC roles — contributor (open PRs and join discussion) or discussant (join discussion only). Non-invited users keep the v0.6.0 anonymous-read contract: they can read but cannot write/discuss that RFC. The platform-level grant (v0.8.0 / item #6) is unchanged; this release adds a per-RFC membership layer beneath it. Migration 018_rfc_invitations.sql adds rfc_invitations (the lifecycle row with the email token and 30-day expiry) and rfc_collaborators (the accepted-invitation substrate the write gate consults). FK-cascaded against cached_rfcs and users per §5. New endpoints (in backend/app/api_invitations.py): POST /api/rfcs/{slug}/invitations (owner) GET /api/rfcs/{slug}/invitations (owner) POST /api/rfcs/{slug}/invitations/{id}/revoke (owner) GET /api/invitations/accept?token=… (signed-in) POST /api/invitations/accept (signed-in) The discussion / branch / open-pr write surfaces compose new auth.can_discuss_rfc and auth.can_contribute_to_rfc predicates after the existing require_contributor check. A super-draft with no frontmatter owners yet falls through to the platform-granted contract — the gate engages only once an owner exists. The email path reuses the existing SMTP plumbing (EmailConfig.from_env) the v0.7.0 OTC and v0.5.0 notification mailers share — transactional envelope, no preferences honored, no unsubscribe footer. The §17 admin user-management surface (item #7, v0.9.0) is extended additively: GET /api/admin/users carries a new per-user rfc_invitations array naming each accepted per-RFC collaboration with inviter / RFC / role / timestamp. The platform-grant decision keeps its context without restructuring the existing shape. Frontend additions: InvitationsModal.jsx (owner-only RFC view header strip affordance), AcceptInvitation.jsx (the /invitations/accept landing page), five api.js helpers. 237 backend tests pass (18 new in test_rfc_invitations_vertical.py, three older tests updated to opt into the per-RFC contributor contract via the new grant_rfc_collaborator test seam). Frontend build clean. No new env vars. No new overlay keys. Migration auto-applied on backend start by the existing db.run_migrations() sweep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
576 lines
22 KiB
Python
576 lines
22 KiB
Python
"""v0.16.0 / §6 / §10 — owner-only invite for per-RFC PR or PR-less
|
|
discussion (roadmap item #12).
|
|
|
|
The RFC's owner can invite a specific email to one of two per-RFC roles:
|
|
|
|
* `contributor` — may open PRs against this RFC AND post in its
|
|
discussion (PR-permission strictly includes discussion-permission).
|
|
* `discussant` — may post in this RFC's PR-less discussion only.
|
|
|
|
Non-invited users keep the v0.6.0 anonymous-read contract: they can
|
|
read but cannot write/discuss the RFC. Reads are not narrowed by
|
|
this item.
|
|
|
|
Endpoints:
|
|
|
|
* `POST /api/rfcs/{slug}/invitations` — owner: create + email
|
|
* `GET /api/rfcs/{slug}/invitations` — owner: list pending/accepted
|
|
* `POST /api/rfcs/{slug}/invitations/{id}/revoke` — owner: revoke
|
|
* `GET /api/invitations/accept` — token lookup (signed-in user)
|
|
* `POST /api/invitations/accept` — token redeem (signed-in user)
|
|
|
|
The accept endpoints are deliberately platform-scoped (not nested under
|
|
the RFC slug) because the user clicking the email link only has the
|
|
token and may not even know the slug yet. The GET shape lets the
|
|
frontend show a confirmation page ("RFC <X> invited you to be a
|
|
<role> — accept?") before the POST commits the membership.
|
|
|
|
Permission gates (composed with `require_contributor`):
|
|
|
|
* Issue / list / revoke: `auth.can_invite_to_rfc` — RFC owner or
|
|
platform admin/owner.
|
|
* Accept: any platform-granted signed-in user; the gate is the
|
|
token, not the role. The token also constrains which email the
|
|
accept lands under — the accepting user's email must match the
|
|
invitation's invitee_email (case-insensitive). This prevents an
|
|
invited-but-not-the-account-holder situation from minting a
|
|
collaborator row under the wrong identity.
|
|
|
|
Email shape: a single plain-text body sent via the existing SMTP path
|
|
(reuses `EmailConfig.from_env()` like `email_otc.py` does). No
|
|
unsubscribe footer — the email is transactional and per-invite, not a
|
|
recurring notification. No tracking pixel.
|
|
|
|
Admin-page hook: when an accept lands and the user's
|
|
`permission_state` is still `pending`, that signals to the admin's
|
|
`/admin/users` queue that the user is here because they accepted a
|
|
per-RFC invitation — informing (not deciding) the admin's
|
|
platform-grant call. v0.16.0 surfaces this via additive columns on
|
|
the existing `GET /api/admin/users` listing (see `api_admin.py`'s
|
|
diff in the same release) — no new endpoint, no restructure.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
import smtplib
|
|
from email.message import EmailMessage
|
|
from email.utils import formataddr
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from . import auth, db
|
|
from .email import EmailConfig, _SENT
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pydantic bodies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class CreateInvitationBody(BaseModel):
|
|
"""The owner picks an email and a role-in-RFC. No custom-message
|
|
field — that belongs to item #16's platform-level invite surface,
|
|
not here.
|
|
|
|
We validate the email with a deliberately narrow pattern rather
|
|
than `pydantic.EmailStr` to avoid pulling in `email-validator` as
|
|
a dependency (and v0.7.0's OTC body does the same — see
|
|
`OTCRequestBody`'s shape). The validation here is intentionally
|
|
permissive: a local-part, an `@`, and a domain part with no
|
|
whitespace. Operator-side typo catching is the job of the email
|
|
transport; the framework only guards against obviously malformed
|
|
input."""
|
|
invitee_email: str = Field(min_length=3, max_length=320,
|
|
pattern=r"^[^\s@]+@[^\s@]+$")
|
|
role_in_rfc: str = Field(pattern="^(contributor|discussant)$")
|
|
|
|
|
|
class AcceptInvitationBody(BaseModel):
|
|
token: str = Field(min_length=1, max_length=200)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 30-day TTL matches the device-trust window the framework already
|
|
# ships (v0.11.0). A pending invitation past this is rejected at the
|
|
# accept endpoint regardless of the row's `status` column.
|
|
INVITATION_TTL_DAYS = 30
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Router
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def make_router() -> APIRouter:
|
|
router = APIRouter()
|
|
|
|
# ---------------------------------------------------------------
|
|
# POST /api/rfcs/<slug>/invitations
|
|
# The owner creates an invitation. The endpoint mints the token,
|
|
# writes the row, and dispatches the email synchronously. A failure
|
|
# to send the email does NOT roll back the row — the owner can
|
|
# share the link directly out-of-band if SMTP is briefly down (the
|
|
# `GET /api/rfcs/<slug>/invitations` response carries the token
|
|
# for that fallback).
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/api/rfcs/{slug}/invitations")
|
|
async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
|
|
viewer = auth.require_contributor(request)
|
|
rfc = _require_rfc(slug)
|
|
if not auth.can_invite_to_rfc(viewer, slug):
|
|
raise HTTPException(
|
|
403,
|
|
"Only the RFC's owner can invite collaborators",
|
|
)
|
|
|
|
invitee_email = body.invitee_email.strip()
|
|
role_in_rfc = body.role_in_rfc
|
|
|
|
# Refuse re-inviting an email that already has a pending
|
|
# invitation on this RFC at the same role. Different-role
|
|
# re-invite is allowed (upgrade discussant → contributor)
|
|
# — the new row supersedes the old in the UI listing's
|
|
# natural ordering, and acceptance of either picks up the
|
|
# corresponding role.
|
|
existing = db.conn().execute(
|
|
"""
|
|
SELECT id FROM rfc_invitations
|
|
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
|
|
AND role_in_rfc = ? AND status = 'pending'
|
|
LIMIT 1
|
|
""",
|
|
(slug, invitee_email, role_in_rfc),
|
|
).fetchone()
|
|
if existing:
|
|
raise HTTPException(
|
|
409,
|
|
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
|
|
)
|
|
|
|
token = _mint_token()
|
|
cur = db.conn().execute(
|
|
"""
|
|
INSERT INTO rfc_invitations
|
|
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
|
|
token, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
|
|
""",
|
|
(
|
|
slug,
|
|
viewer.user_id,
|
|
invitee_email,
|
|
role_in_rfc,
|
|
token,
|
|
f"+{INVITATION_TTL_DAYS} days",
|
|
),
|
|
)
|
|
invitation_id = cur.lastrowid
|
|
|
|
# Send the email — synchronous. A send failure logs and
|
|
# returns; the row stays so the owner can recover via the
|
|
# listing (which carries the token for an out-of-band share).
|
|
_send_invitation_email(
|
|
to_address=invitee_email,
|
|
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
|
|
rfc_title=rfc["title"],
|
|
role_in_rfc=role_in_rfc,
|
|
token=token,
|
|
)
|
|
|
|
return {
|
|
"id": invitation_id,
|
|
"rfc_slug": slug,
|
|
"invitee_email": invitee_email,
|
|
"role_in_rfc": role_in_rfc,
|
|
"status": "pending",
|
|
"token": token,
|
|
}
|
|
|
|
# ---------------------------------------------------------------
|
|
# GET /api/rfcs/<slug>/invitations
|
|
# The owner's listing of every invitation on the RFC, regardless
|
|
# of status. Carries the token (for the resend / re-share path).
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/rfcs/{slug}/invitations")
|
|
async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
|
|
viewer = auth.require_contributor(request)
|
|
_require_rfc(slug)
|
|
if not auth.can_invite_to_rfc(viewer, slug):
|
|
raise HTTPException(
|
|
403,
|
|
"Only the RFC's owner can view invitations",
|
|
)
|
|
|
|
rows = db.conn().execute(
|
|
"""
|
|
SELECT i.id, i.invitee_email, i.role_in_rfc, i.status, i.token,
|
|
i.expires_at, i.created_at, i.accepted_at,
|
|
i.inviter_user_id, i.accepted_by_user_id,
|
|
u_inviter.display_name AS inviter_display,
|
|
u_inviter.gitea_login AS inviter_login,
|
|
u_accept.display_name AS accepted_by_display,
|
|
u_accept.gitea_login AS accepted_by_login
|
|
FROM rfc_invitations i
|
|
LEFT JOIN users u_inviter ON u_inviter.id = i.inviter_user_id
|
|
LEFT JOIN users u_accept ON u_accept.id = i.accepted_by_user_id
|
|
WHERE i.rfc_slug = ?
|
|
ORDER BY i.id DESC
|
|
""",
|
|
(slug,),
|
|
).fetchall()
|
|
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": r["id"],
|
|
"invitee_email": r["invitee_email"],
|
|
"role_in_rfc": r["role_in_rfc"],
|
|
"status": _effective_status(r),
|
|
"token": r["token"],
|
|
"expires_at": r["expires_at"],
|
|
"created_at": r["created_at"],
|
|
"accepted_at": r["accepted_at"],
|
|
"inviter_display": r["inviter_display"],
|
|
"inviter_login": r["inviter_login"],
|
|
"accepted_by_display": r["accepted_by_display"],
|
|
"accepted_by_login": r["accepted_by_login"],
|
|
}
|
|
for r in rows
|
|
],
|
|
}
|
|
|
|
# ---------------------------------------------------------------
|
|
# POST /api/rfcs/<slug>/invitations/<id>/revoke
|
|
# Revokes a pending invitation. Already-accepted invitations
|
|
# cannot be "revoked" from this surface — the corresponding
|
|
# collaborator-removal surface is a §19.2 candidate; v0.16.0
|
|
# only lifts the *pending* link.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
|
|
async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
|
|
viewer = auth.require_contributor(request)
|
|
_require_rfc(slug)
|
|
if not auth.can_invite_to_rfc(viewer, slug):
|
|
raise HTTPException(
|
|
403,
|
|
"Only the RFC's owner can revoke invitations",
|
|
)
|
|
|
|
row = db.conn().execute(
|
|
"SELECT id, status FROM rfc_invitations WHERE id = ? AND rfc_slug = ?",
|
|
(invitation_id, slug),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "Invitation not found")
|
|
if row["status"] != "pending":
|
|
raise HTTPException(
|
|
409,
|
|
f"Invitation is {row['status']}; only pending invitations can be revoked",
|
|
)
|
|
|
|
db.conn().execute(
|
|
"UPDATE rfc_invitations SET status = 'revoked' WHERE id = ?",
|
|
(invitation_id,),
|
|
)
|
|
return {"ok": True, "id": invitation_id, "status": "revoked"}
|
|
|
|
# ---------------------------------------------------------------
|
|
# GET /api/invitations/accept?token=...
|
|
# Lookup-only — returns what the invitation grants so the
|
|
# frontend can render a confirmation page before the POST. The
|
|
# token is required; no token, no peek.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/invitations/accept")
|
|
async def preview_invitation(token: str, request: Request) -> dict[str, Any]:
|
|
viewer = auth.require_user(request)
|
|
row = _lookup_invitation_by_token(token)
|
|
if row is None:
|
|
raise HTTPException(404, "Invitation not found")
|
|
effective = _effective_status(row)
|
|
rfc = db.conn().execute(
|
|
"SELECT slug, title FROM cached_rfcs WHERE slug = ?", (row["rfc_slug"],),
|
|
).fetchone()
|
|
return {
|
|
"rfc_slug": row["rfc_slug"],
|
|
"rfc_title": rfc["title"] if rfc else row["rfc_slug"],
|
|
"role_in_rfc": row["role_in_rfc"],
|
|
"status": effective,
|
|
"invitee_email": row["invitee_email"],
|
|
"email_matches_you": (viewer.email or "").strip().lower()
|
|
== row["invitee_email"].strip().lower(),
|
|
"expires_at": row["expires_at"],
|
|
}
|
|
|
|
# ---------------------------------------------------------------
|
|
# POST /api/invitations/accept
|
|
# The accept gesture: token → collaborator row.
|
|
#
|
|
# Requires:
|
|
# * an authenticated user (no token-only acceptance — we want
|
|
# the per-user audit trail),
|
|
# * a valid (pending, non-expired, non-revoked) invitation,
|
|
# * the accepting user's email matches invitee_email
|
|
# (case-insensitive).
|
|
#
|
|
# On success the row's status flips to 'accepted' and a
|
|
# rfc_collaborators row is inserted (or upgraded if the user
|
|
# already had a lower role). Idempotent: re-accepting the same
|
|
# already-accepted invitation reads as a 200 no-op with
|
|
# `changed=false`.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/api/invitations/accept")
|
|
async def accept_invitation(body: AcceptInvitationBody, request: Request) -> dict[str, Any]:
|
|
viewer = auth.require_user(request)
|
|
row = _lookup_invitation_by_token(body.token)
|
|
if row is None:
|
|
raise HTTPException(404, "Invitation not found")
|
|
|
|
effective = _effective_status(row)
|
|
if effective == "revoked":
|
|
raise HTTPException(409, "Invitation was revoked")
|
|
if effective == "expired":
|
|
raise HTTPException(409, "Invitation has expired")
|
|
|
|
# Email match — case-insensitive. Empty viewer email cannot
|
|
# accept (an OAuth-only user with no captured email shape).
|
|
viewer_email = (viewer.email or "").strip().lower()
|
|
invitee_email = row["invitee_email"].strip().lower()
|
|
if not viewer_email or viewer_email != invitee_email:
|
|
raise HTTPException(
|
|
403,
|
|
"This invitation was sent to a different email; sign in with that address",
|
|
)
|
|
|
|
if effective == "accepted":
|
|
# Idempotent re-accept — surface the existing collaborator
|
|
# row without writing anything new.
|
|
collab = db.conn().execute(
|
|
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
|
|
(row["rfc_slug"], viewer.user_id),
|
|
).fetchone()
|
|
return {
|
|
"ok": True,
|
|
"changed": False,
|
|
"rfc_slug": row["rfc_slug"],
|
|
"role_in_rfc": collab["role_in_rfc"] if collab else row["role_in_rfc"],
|
|
}
|
|
|
|
# First-time accept. Flip the invitation; upsert the
|
|
# collaborator. We do the upsert with ON CONFLICT so a
|
|
# user who already held a lower role gets upgraded, never
|
|
# downgraded (the MAX-style precedence is contributor >
|
|
# discussant; lower roles never overwrite higher).
|
|
with db.tx() as c:
|
|
c.execute(
|
|
"""
|
|
UPDATE rfc_invitations
|
|
SET status = 'accepted',
|
|
accepted_at = datetime('now'),
|
|
accepted_by_user_id = ?
|
|
WHERE id = ?
|
|
""",
|
|
(viewer.user_id, row["id"]),
|
|
)
|
|
existing = c.execute(
|
|
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
|
|
(row["rfc_slug"], viewer.user_id),
|
|
).fetchone()
|
|
target_role = _max_role(
|
|
existing["role_in_rfc"] if existing else None,
|
|
row["role_in_rfc"],
|
|
)
|
|
if existing is None:
|
|
c.execute(
|
|
"""
|
|
INSERT INTO rfc_collaborators
|
|
(rfc_slug, user_id, role_in_rfc, invitation_id)
|
|
VALUES (?, ?, ?, ?)
|
|
""",
|
|
(row["rfc_slug"], viewer.user_id, target_role, row["id"]),
|
|
)
|
|
elif existing["role_in_rfc"] != target_role:
|
|
c.execute(
|
|
"""
|
|
UPDATE rfc_collaborators
|
|
SET role_in_rfc = ?, invitation_id = ?
|
|
WHERE rfc_slug = ? AND user_id = ?
|
|
""",
|
|
(target_role, row["id"], row["rfc_slug"], viewer.user_id),
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"changed": True,
|
|
"rfc_slug": row["rfc_slug"],
|
|
"role_in_rfc": target_role,
|
|
}
|
|
|
|
return router
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _require_rfc(slug: str):
|
|
"""The invitation surface only operates on a known, non-withdrawn
|
|
RFC. We refuse 404 on unknown and 409 on withdrawn — mirrors the
|
|
discussion endpoints' `_require_rfc_readable` shape."""
|
|
row = db.conn().execute(
|
|
"SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "RFC not found")
|
|
if row["state"] == "withdrawn":
|
|
raise HTTPException(409, "RFC is withdrawn")
|
|
return row
|
|
|
|
|
|
def _lookup_invitation_by_token(token: str):
|
|
return db.conn().execute(
|
|
"""
|
|
SELECT id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
|
|
status, token, expires_at, created_at, accepted_at,
|
|
accepted_by_user_id
|
|
FROM rfc_invitations
|
|
WHERE token = ?
|
|
""",
|
|
(token,),
|
|
).fetchone()
|
|
|
|
|
|
def _effective_status(row) -> str:
|
|
"""The row's column status is the authoritative truth except for
|
|
`expired` — that is derived from `expires_at` at read time so an
|
|
unattended cron isn't required to flip rows. A revoked-then-
|
|
expired row reads as `revoked` (the explicit gesture wins)."""
|
|
column_status = row["status"]
|
|
if column_status != "pending":
|
|
return column_status
|
|
# Compare via SQL so the comparison is in sqlite-time, matching the
|
|
# `datetime('now')` insert. A simpler same-process comparison would
|
|
# work too, but routing through the DB keeps the timezone handling
|
|
# consistent with the inserts.
|
|
is_past = db.conn().execute(
|
|
"SELECT datetime(?) <= datetime('now') AS past",
|
|
(row["expires_at"],),
|
|
).fetchone()["past"]
|
|
return "expired" if is_past else "pending"
|
|
|
|
|
|
def _mint_token() -> str:
|
|
"""A 256-bit URL-safe token. The token shape is opaque to the
|
|
consumer; the email link encodes it as a query param."""
|
|
return secrets.token_urlsafe(32)
|
|
|
|
|
|
def _max_role(existing: str | None, new: str) -> str:
|
|
"""contributor strictly dominates discussant. A re-accept that
|
|
would lower the role is a no-op (the existing role survives)."""
|
|
precedence = {"discussant": 0, "contributor": 1}
|
|
if existing is None:
|
|
return new
|
|
if precedence.get(new, 0) > precedence.get(existing, 0):
|
|
return new
|
|
return existing
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Email dispatch — transactional, no preferences honored
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _send_invitation_email(
|
|
*,
|
|
to_address: str,
|
|
inviter_display: str,
|
|
rfc_title: str,
|
|
role_in_rfc: str,
|
|
token: str,
|
|
) -> bool:
|
|
"""Compose and send the invitation email.
|
|
|
|
Like `email_otc.send_otc_email`, this writes its own envelope and
|
|
reuses `EmailConfig.from_env()` for the SMTP plumbing. The
|
|
`_SENT` buffer is appended either way so integration tests can
|
|
assert on the outbound shape without a real SMTP server.
|
|
|
|
Returns True on the happy path / dev fallback; False on SMTP
|
|
failure. The caller does not roll back the invitation row on
|
|
failure — the owner has the token in the create response and on
|
|
the listing surface for an out-of-band share.
|
|
"""
|
|
cfg = EmailConfig.from_env()
|
|
subject = f"{inviter_display} invited you to {rfc_title} on {cfg.from_name}"
|
|
role_label = (
|
|
"open PRs against the RFC and join its discussion"
|
|
if role_in_rfc == "contributor"
|
|
else "join the RFC's discussion"
|
|
)
|
|
link = f"{cfg.app_url}/invitations/accept?token={token}"
|
|
body = (
|
|
f"{inviter_display} invited you to {rfc_title} on {cfg.from_name} as {role_in_rfc}.\n\n"
|
|
f"This invitation lets you {role_label}.\n\n"
|
|
f"Click to accept (you'll be asked to sign in first if you aren't already):\n\n"
|
|
f" {link}\n\n"
|
|
f"The invitation expires in {INVITATION_TTL_DAYS} days. If you weren't expecting\n"
|
|
f"this, you can safely ignore the email.\n\n"
|
|
f"---\n"
|
|
f"{cfg.from_name} · {cfg.app_url}\n"
|
|
)
|
|
envelope = {
|
|
"to": to_address,
|
|
"from": formataddr((cfg.from_name, cfg.from_address)),
|
|
"subject": subject,
|
|
"body": body,
|
|
"kind": "rfc_invitation",
|
|
}
|
|
_SENT.append(envelope)
|
|
|
|
if not cfg.enabled:
|
|
log.info("invitation email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
|
return True
|
|
if not cfg.smtp_host:
|
|
# Dev fallback — surface the link at INFO so the operator can
|
|
# complete an accept flow without an SMTP relay.
|
|
log.info(
|
|
"invitation email (stdout fallback): to=%s rfc=%s role=%s link=%s",
|
|
to_address, rfc_title, role_in_rfc, link,
|
|
)
|
|
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("invitation email send failed: to=%s", to_address)
|
|
return False
|