Invite a friend; handle field shows the domain; system mail fixes
- Invitations: each user can invite two friends by email (sign-up works through the link even while registration is closed); admin-editable template, subject and sender. - Invitation sender defaults to the system-mail From address, which the SMTP account is allowed to send as (was postmaster@APP_DOMAIN, refused with 553 when the two differ). - Sign-in, sign-up and forgot-password fields show the fixed @domain after the typed name; the registration status reports it. - System mail gets a Date header; the invitation's plain-text part keeps its paragraphs and line breaks. - SMTP EHLO uses SMTP_HELO_NAME / APP_DOMAIN, not the container name. - DNS recommendations for the mail domains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"""Invite a friend (app/identity/invitations.py): what the sidebar entry and its
|
||||
dialog need — how many invitations are left, and sending one."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.db import get_session
|
||||
from app.identity import invitations
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.identity.mailer import MailerError, mailer_config, send_mail
|
||||
from app.identity.service import app_settings
|
||||
from app.models import Invitation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/invitations", tags=["invitations"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_EMAIL_RE = re.compile(r"[^@\s<>\"]+@[^@\s<>\"]+\.[^@\s<>\"]+")
|
||||
|
||||
|
||||
class SentInvitationOut(BaseModel):
|
||||
invitee_name: str
|
||||
invitee_email: str
|
||||
created_at: str
|
||||
used: bool
|
||||
expired: bool
|
||||
|
||||
|
||||
class InvitationsOut(BaseModel):
|
||||
# Invitations are switched on and system mail can send them.
|
||||
available: bool
|
||||
remaining: int
|
||||
per_user: int
|
||||
sent: list[SentInvitationOut]
|
||||
|
||||
|
||||
class InvitationIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
|
||||
|
||||
@router.get("", response_model=InvitationsOut)
|
||||
async def read(user: CurrentUser, db: DbSession) -> InvitationsOut:
|
||||
row = await app_settings(db)
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
return InvitationsOut(
|
||||
available=row.invitations_enabled and mailer_config(row) is not None,
|
||||
remaining=await invitations.remaining(db, user.id),
|
||||
per_user=invitations.PER_USER,
|
||||
sent=[
|
||||
SentInvitationOut(
|
||||
invitee_name=i.invitee_name,
|
||||
invitee_email=i.invitee_email,
|
||||
created_at=i.created_at.isoformat(),
|
||||
used=i.used_at is not None,
|
||||
expired=i.used_at is None and i.expires_at <= now,
|
||||
)
|
||||
for i in await invitations.sent(db, user.id)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=InvitationsOut, dependencies=[Depends(require_csrf)])
|
||||
async def send(body: InvitationIn, user: CurrentUser, db: DbSession) -> InvitationsOut:
|
||||
"""Email a friend a single-use sign-up link. Sent before answering, so the
|
||||
user hears at once if it could not go out (and keeps the invitation)."""
|
||||
name = " ".join(body.name.split())
|
||||
email = body.email.strip()
|
||||
if not name:
|
||||
raise_api_error(400, "invalid_name", "enter your friend's name")
|
||||
if not _EMAIL_RE.fullmatch(email):
|
||||
raise_api_error(400, "invalid_email", "enter a valid email address")
|
||||
row = await app_settings(db)
|
||||
if not row.invitations_enabled:
|
||||
raise_api_error(403, "invitations_disabled", "invitations are switched off")
|
||||
cfg = mailer_config(row)
|
||||
if cfg is None:
|
||||
raise_api_error(503, "mail_not_configured", "the server cannot send email yet")
|
||||
try:
|
||||
invitation, token = await invitations.create(db, user, name=name, email=email)
|
||||
except invitations.InvitationError as exc:
|
||||
raise_api_error(409, exc.code, exc.message)
|
||||
await db.commit()
|
||||
|
||||
# The token rides in the fragment: never sent to a server, so it stays out
|
||||
# of access logs and Referer headers (as with password-reset links).
|
||||
link = f"{cfg.public_url}/register#invite={token}"
|
||||
mail = invitations.render(
|
||||
row,
|
||||
invitee_name=name,
|
||||
inviter_name=await invitations.inviter_name(db, user),
|
||||
link=link,
|
||||
)
|
||||
try:
|
||||
await send_mail(
|
||||
cfg,
|
||||
email,
|
||||
mail.subject,
|
||||
mail.text,
|
||||
mail.html,
|
||||
sender=invitations.sender_address(row),
|
||||
reply_to=user.handle, # an answer reaches the friend who invited
|
||||
inline=mail.inline,
|
||||
)
|
||||
except MailerError as exc:
|
||||
logger.warning("invitation from %s could not be sent: %s", user.handle, exc)
|
||||
# Not sent: the user keeps the invitation.
|
||||
await db.execute(delete(Invitation).where(Invitation.id == invitation.id))
|
||||
await db.commit()
|
||||
raise_api_error(502, "mail_failed", "the invitation could not be sent — try again later")
|
||||
return await read(user, db)
|
||||
Reference in New Issue
Block a user