503689bf1a
Builds the three-tier authorization resolver on M1's schema spine: the most-permissive union of deployment role (§6.1), project role (§22.6), and per-RFC authority (§6.3/§12), with the §22.5 visibility gate subtractive on top (§22.7). Pure app-layer — no migration (M1 shipped the tables), no behavior change on the public default project. Verifiable on the single default project by flipping its visibility and granting/revoking roles. - app/auth.py: the §22.7 resolver primitives — project_visibility, project_member_role, project_of_rfc, is_project_superuser, can_read_project, effective write/discuss standing (can_contribute_in_project / can_discuss_in_project), require_project_readable, visible_project_ids. The three per-RFC capability helpers (can_discuss_rfc / can_contribute_to_rfc / can_invite_to_rfc) now compose all three tiers, so the ~20 call sites inherit M2 unchanged. - Read pass: the §22.5 visibility gate (404 to non-members) threaded into every RFC-resolution helper across api.py / api_branches / api_prs / api_graduation / api_discussion / api_contributions / api_invitations, plus the catalog + proposals listings filtered by visible_project_ids. - Write pass: the deep gates (branch read/contribute/owner, PR merge/withdraw/ edit, graduation, discussion resolve) fold in project_admin via is_project_superuser; the "any-contributor" branch mode routes through can_contribute_in_project; propose + claim gate on project contribute standing. Deployment-level surfaces (admin idea-PR merge/decline, account notification-mute) stay deployment-scoped. - Operator decisions: implicit-on-public (a granted deployment contributor keeps its pre-M2 write baseline on a public project, no membership row, so the N=1 case stays whole) and preserve-curation (that baseline does not override per-RFC owner curation — only an explicit project grant or a deployment owner/admin does), keeping the v0.16.0 per-RFC invite contract intact on public. - tests: test_multi_project_authz_vertical.py — 9 vertical assertions on the resolver tiers, public-unchanged regression, the gated 404 read gate, the gated contribution gate, union + subtractive visibility, and revocation. Full suite 390 passed. - docs: mark Part C M2 "(landed)" with the two operator decisions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
597 lines
23 KiB
Python
597 lines
23 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, viewer)
|
|
if not auth.can_invite_to_rfc(viewer, slug):
|
|
raise HTTPException(
|
|
403,
|
|
"Only the RFC's owner can invite collaborators",
|
|
)
|
|
|
|
return issue_invitation(
|
|
slug=slug,
|
|
inviter_user_id=viewer.user_id,
|
|
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
|
|
invitee_email=body.invitee_email,
|
|
role_in_rfc=body.role_in_rfc,
|
|
rfc_title=rfc["title"],
|
|
)
|
|
|
|
# ---------------------------------------------------------------
|
|
# 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, viewer)
|
|
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, viewer)
|
|
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, viewer):
|
|
"""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. The §22.5
|
|
visibility gate is subtractive: a gated project's entries 404 to
|
|
non-members (§22.7)."""
|
|
row = db.conn().execute(
|
|
"SELECT slug, title, state, project_id FROM cached_rfcs WHERE slug = ?", (slug,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "RFC not found")
|
|
auth.require_project_readable(viewer, row["project_id"])
|
|
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 issue_invitation(
|
|
*,
|
|
slug: str,
|
|
inviter_user_id: int,
|
|
inviter_display: str,
|
|
invitee_email: str,
|
|
role_in_rfc: str,
|
|
rfc_title: str,
|
|
) -> dict:
|
|
"""Mint + persist + email one ``rfc_invitations`` row.
|
|
|
|
The single chokepoint for issuing an invitation: the owner's manual
|
|
`POST /api/rfcs/{slug}/invitations` endpoint and roadmap #28 Part 3's
|
|
accept path both route through here, so the dup-guard, token mint,
|
|
insert, and transactional email stay identical.
|
|
|
|
Refuses (409) re-inviting an email that already has a pending
|
|
invitation on this RFC at the same role. A different-role re-invite is
|
|
allowed (the discussant → contributor upgrade) — the new row
|
|
supersedes the old in the listing's natural ordering, and acceptance
|
|
of either picks up the corresponding role.
|
|
|
|
Returns the new row's dict (including the raw token, for the owner's
|
|
out-of-band share / the caller's record-keeping). A send failure logs
|
|
and returns; the row stays so the owner can recover via the listing.
|
|
"""
|
|
invitee_email = invitee_email.strip()
|
|
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, inviter_user_id, invitee_email, role_in_rfc, token, f"+{INVITATION_TTL_DAYS} days"),
|
|
)
|
|
invitation_id = cur.lastrowid
|
|
|
|
_send_invitation_email(
|
|
to_address=invitee_email,
|
|
inviter_display=inviter_display,
|
|
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,
|
|
}
|
|
|
|
|
|
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
|