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
+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 --------------------------------------------------------------------