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
+49
View File
@@ -12,6 +12,9 @@ class RegisterRequest(BaseModel):
localpart: str = Field(min_length=3, max_length=30)
password: str = Field(min_length=8, max_length=128)
recovery_email: str | None = None
# A friend's invitation (app/identity/invitations.py): signs up even while
# public registration is closed, and is used up by it.
invite: str | None = Field(default=None, max_length=200)
class LoginRequest(BaseModel):
@@ -98,23 +101,39 @@ class UserOut(BaseModel):
mail_host: str | None = None
class InvitationCheckIn(BaseModel):
token: str = Field(min_length=1, max_length=200)
class InvitationCheckOut(BaseModel):
valid: bool
invitee_name: str = ""
inviter_name: str = ""
class RegistrationStatusOut(BaseModel):
"""Public, unauthenticated: what the signup page should show."""
open: bool
# True when the instance has no users yet, so signup creates the administrator.
bootstrap: bool
# The domain every handle lives under (localpart@domain), shown beside the
# handle field.
domain: str
class AdminSettingsOut(BaseModel):
registration_open: bool
default_quota_bytes: int
# Users may send friends sign-up invitations (app/identity/invitations.py).
invitations_enabled: bool
class AdminSettingsIn(BaseModel):
"""Merge semantics: omitted fields are left as they are."""
registration_open: bool | None = None
invitations_enabled: bool | None = None
# Applies to users created after the change; existing mailboxes keep theirs.
default_quota_bytes: int | None = Field(default=None, gt=0)
@@ -145,6 +164,36 @@ class AdminMailIn(BaseModel):
public_url: str | None = Field(default=None, max_length=500)
class AdminInviteTemplateOut(BaseModel):
# Empty strings: the built-in subject / template / sender is used.
subject: str
html: str
from_address: str
default_subject: str
default_html: str
default_from: str
placeholders: list[str]
class AdminInviteTemplateIn(BaseModel):
"""Merge semantics: omitted fields are left as they are; an empty string
goes back to the built-in one."""
subject: str | None = Field(default=None, max_length=300)
html: str | None = None
from_address: str | None = Field(default=None, max_length=320)
class AdminInvitePreviewIn(BaseModel):
subject: str = Field(default="", max_length=300)
html: str = ""
class AdminInvitePreviewOut(BaseModel):
subject: str
html: str
class AdminMailTestIn(BaseModel):
to: str = Field(min_length=3, max_length=320)