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:
2026-10-15 19:34:31 +02:00
co-authored by Claude Opus 5.5
parent addb2232a1
commit 463f96ad57
30 changed files with 1870 additions and 42 deletions
+2
View File
@@ -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
View File
@@ -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 --------------------------------------------------------------------
+123
View File
@@ -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)