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
+36 -2
View File
@@ -18,7 +18,7 @@ from app.config import get_settings
from app.db import get_session
from app.events import get_event_bus
from app.hosted.admin import MailServerError
from app.identity import app_passwords, trusted_devices
from app.identity import app_passwords, invitations, trusted_devices
from app.identity import totp as totp_mod
from app.identity.cookies import (
clear_auth_cookies,
@@ -40,6 +40,8 @@ from app.identity.schemas import (
AppPasswordCreatedOut,
AppPasswordIn,
AppPasswordOut,
InvitationCheckIn,
InvitationCheckOut,
LoginRequest,
MessageOut,
PasswordResetCheckIn,
@@ -116,12 +118,31 @@ def _user_out(user: User) -> UserOut:
async def registration(db: DbSession) -> RegistrationStatusOut:
"""Whether signup is open. Unauthenticated: the signup page needs it to render."""
status = await registration_status(db)
return RegistrationStatusOut(open=status.open, bootstrap=status.bootstrap)
return RegistrationStatusOut(
open=status.open, bootstrap=status.bootstrap, domain=get_settings().app_domain
)
_REGISTRATION_ERROR_STATUS = {"handle_taken": 409, "registration_closed": 403}
@router.post("/invitation/check", response_model=InvitationCheckOut)
async def invitation_check(body: InvitationCheckIn, db: DbSession) -> InvitationCheckOut:
"""Whether an invitation link still works, and who it is from — for the
sign-up page. Does not use it up."""
if not (await app_settings(db)).invitations_enabled:
return InvitationCheckOut(valid=False)
invitation = await invitations.find_valid(db, body.token)
if invitation is None:
return InvitationCheckOut(valid=False)
inviter = await db.get(User, invitation.inviter_id)
return InvitationCheckOut(
valid=True,
invitee_name=invitation.invitee_name,
inviter_name=await invitations.inviter_name(db, inviter) if inviter else "",
)
@router.post("/register", response_model=UserOut)
async def register(
body: RegisterRequest, request: Request, response: Response, db: DbSession
@@ -130,15 +151,28 @@ async def register(
await check_new_password(body.password, resolve_handle(body.localpart))
except PasswordRejected as exc:
raise_api_error(400, exc.code, exc.message)
invitation_id: uuid.UUID | None = None
if body.invite:
# Used up in this same transaction: if the sign-up fails (the address
# is taken, …) the rollback hands the invitation back.
if (await app_settings(db)).invitations_enabled:
invitation_id = await invitations.claim(db, body.invite)
if invitation_id is None:
raise_api_error(
400, "invalid_invitation", "this invitation has already been used or has expired"
)
try:
user = await register_user(
db,
localpart=body.localpart,
password=body.password,
recovery_email=body.recovery_email,
invited=invitation_id is not None,
)
except RegistrationError as exc:
raise_api_error(_REGISTRATION_ERROR_STATUS.get(exc.code, 400), exc.code, exc.message)
if invitation_id is not None:
await invitations.record_user(db, invitation_id, user.id)
await db.commit()
# The user's own mailbox (App. D §D.4): it exists on the mail server as soon
# as the user does (the directory reads the users table); this adds it to