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:
@@ -16,6 +16,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.avatar import router as avatar_router
|
||||
from app.api.compose import router as compose_router
|
||||
from app.api.invitations import router as invitations_router
|
||||
from app.api.mailboxes import router as mailboxes_router
|
||||
from app.api.messages import router as messages_router
|
||||
from app.api.plugins import router as plugins_router
|
||||
@@ -46,6 +47,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
admin_router,
|
||||
spellcheck_router,
|
||||
push_router,
|
||||
invitations_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
|
||||
+122
-1
@@ -9,7 +9,9 @@ their own account, so the instance can never lock out its last admin that way.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import re
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
@@ -18,11 +20,24 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.hosted.admin import MailServerError
|
||||
from app.identity import invitations
|
||||
from app.identity.deps import CurrentAdmin, require_csrf
|
||||
from app.identity.mailer import LOGO_HTML, MailerError, mailer_config, send_mail
|
||||
from app.identity.mailer import (
|
||||
LOGO_CID,
|
||||
LOGO_DATA_URL,
|
||||
LOGO_HTML,
|
||||
MailerError,
|
||||
mailer_config,
|
||||
send_mail,
|
||||
)
|
||||
from app.identity.schemas import (
|
||||
AdminInvitePreviewIn,
|
||||
AdminInvitePreviewOut,
|
||||
AdminInviteTemplateIn,
|
||||
AdminInviteTemplateOut,
|
||||
AdminMailIn,
|
||||
AdminMailOut,
|
||||
AdminMailTestIn,
|
||||
@@ -46,6 +61,7 @@ def _out(row: AppSettings) -> AdminSettingsOut:
|
||||
return AdminSettingsOut(
|
||||
registration_open=row.registration_open,
|
||||
default_quota_bytes=row.default_quota_bytes,
|
||||
invitations_enabled=row.invitations_enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +84,8 @@ async def write_settings(
|
||||
row.registration_open = body.registration_open
|
||||
if body.default_quota_bytes is not None:
|
||||
row.default_quota_bytes = body.default_quota_bytes
|
||||
if body.invitations_enabled is not None:
|
||||
row.invitations_enabled = body.invitations_enabled
|
||||
row.updated_at = dt.datetime.now(dt.UTC)
|
||||
await db.commit()
|
||||
return _out(row)
|
||||
@@ -141,6 +159,109 @@ async def test_mail(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -
|
||||
return MessageOut()
|
||||
|
||||
|
||||
# --- Invitation email ------------------------------------------------------------
|
||||
|
||||
_EMAIL_RE = re.compile(r"[^@\s<>\"]+@[^@\s<>\"]+\.[^@\s<>\"]+")
|
||||
_SAMPLE = {"invitee_name": "Anna Nowak", "inviter_name": "Jan Kowalski"}
|
||||
|
||||
|
||||
def _template_out(row: AppSettings) -> AdminInviteTemplateOut:
|
||||
return AdminInviteTemplateOut(
|
||||
subject=row.invite_subject,
|
||||
html=row.invite_template,
|
||||
from_address=row.invite_from,
|
||||
default_subject=invitations.DEFAULT_SUBJECT,
|
||||
default_html=invitations.DEFAULT_TEMPLATE,
|
||||
default_from=row.smtp_from,
|
||||
placeholders=list(invitations.PLACEHOLDERS),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/invitations/template", response_model=AdminInviteTemplateOut)
|
||||
async def read_invite_template(admin: CurrentAdmin, db: DbSession) -> AdminInviteTemplateOut:
|
||||
return _template_out(await app_settings(db))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/invitations/template",
|
||||
response_model=AdminInviteTemplateOut,
|
||||
dependencies=[Depends(require_csrf)],
|
||||
)
|
||||
async def write_invite_template(
|
||||
body: AdminInviteTemplateIn, admin: CurrentAdmin, db: DbSession
|
||||
) -> AdminInviteTemplateOut:
|
||||
"""Upload the invitation email's HTML template, subject and sender."""
|
||||
row = await app_settings(db, for_update=True)
|
||||
if body.html is not None:
|
||||
html = body.html.strip()
|
||||
if html and (problem := invitations.template_problem(html)):
|
||||
raise_api_error(400, "invalid_template", problem)
|
||||
row.invite_template = html
|
||||
if body.subject is not None:
|
||||
row.invite_subject = " ".join(body.subject.split())
|
||||
if body.from_address is not None:
|
||||
sender = body.from_address.strip()
|
||||
if sender and not _EMAIL_RE.fullmatch(sender):
|
||||
raise_api_error(400, "invalid_email", "the sender must be an email address")
|
||||
row.invite_from = sender
|
||||
row.updated_at = dt.datetime.now(dt.UTC)
|
||||
await db.commit()
|
||||
return _template_out(row)
|
||||
|
||||
|
||||
def _sample(row: AppSettings, subject: str, html: str) -> invitations.RenderedMail:
|
||||
"""The invitation as a friend would get it, with sample names."""
|
||||
if html.strip() and (problem := invitations.template_problem(html)):
|
||||
raise_api_error(400, "invalid_template", problem)
|
||||
draft = AppSettings(invite_subject=subject, invite_template=html.strip())
|
||||
base = row.public_url.rstrip("/") or f"https://{get_settings().app_domain}"
|
||||
return invitations.render(draft, link=f"{base}/register#invite=sample", **_SAMPLE)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/invitations/preview",
|
||||
response_model=AdminInvitePreviewOut,
|
||||
dependencies=[Depends(require_csrf)],
|
||||
)
|
||||
async def preview_invite(
|
||||
body: AdminInvitePreviewIn, admin: CurrentAdmin, db: DbSession
|
||||
) -> AdminInvitePreviewOut:
|
||||
"""Render a template (saved or not) with sample names, for the panel to show."""
|
||||
mail = _sample(await app_settings(db), body.subject, body.html)
|
||||
html = mail.html
|
||||
# The preview frame has no message parts: show the images it would carry.
|
||||
for part in mail.inline:
|
||||
data = base64.b64encode(part.data).decode()
|
||||
html = html.replace(f"cid:{part.cid}", f"data:{part.mime_type};base64,{data}")
|
||||
html = html.replace(f"cid:{LOGO_CID}", LOGO_DATA_URL)
|
||||
return AdminInvitePreviewOut(subject=mail.subject, html=html)
|
||||
|
||||
|
||||
@router.post("/invitations/test", response_model=MessageOut, dependencies=[Depends(require_csrf)])
|
||||
async def test_invite(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut:
|
||||
"""Send the saved invitation email, with sample names and a dummy link."""
|
||||
row = await app_settings(db)
|
||||
cfg = mailer_config(row)
|
||||
if cfg is None:
|
||||
raise_api_error(
|
||||
409, "mail_not_configured", "set the SMTP server, From address and public address first"
|
||||
)
|
||||
mail = _sample(row, row.invite_subject, row.invite_template)
|
||||
try:
|
||||
await send_mail(
|
||||
cfg,
|
||||
body.to.strip(),
|
||||
mail.subject,
|
||||
mail.text,
|
||||
mail.html,
|
||||
sender=invitations.sender_address(row),
|
||||
inline=mail.inline,
|
||||
)
|
||||
except MailerError as exc:
|
||||
raise_api_error(502, "mail_failed", f"sending failed: {exc}")
|
||||
return MessageOut()
|
||||
|
||||
|
||||
# --- Users --------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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