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:
@@ -0,0 +1,240 @@
|
||||
"""Invitations: a user sends a friend a single-use sign-up link.
|
||||
|
||||
While the administrator has invitations switched on, every user may send
|
||||
``PER_USER`` of them. A link lets exactly one account be created — even with
|
||||
public registration closed — and works for ``VALID_FOR``; one that ran out
|
||||
unused is handed back to its sender. Only a hash of the link's token is kept.
|
||||
|
||||
The email is built from the administrator's template (or the built-in one)
|
||||
with these placeholders, written ``{{name}}``:
|
||||
|
||||
invitee_name the friend's name, as the sender typed it
|
||||
inviter_name the sender's name (their mail account's display name,
|
||||
else their address)
|
||||
invite_link the sign-up address itself
|
||||
invite_button a ready-made button linking to it
|
||||
logo the Ultimo Email logo
|
||||
|
||||
Names are HTML-escaped where they go into the HTML: they come from users and
|
||||
end up in mail to strangers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import html as html_lib
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
import nh3
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.mailer import LOGO_HTML
|
||||
from app.models import Account, AppSettings, Invitation, User
|
||||
from app.outbox.inline_images import InlinePart, data_image
|
||||
|
||||
PER_USER = 2
|
||||
VALID_FOR = dt.timedelta(days=14)
|
||||
MAX_TEMPLATE_BYTES = 500 * 1024
|
||||
|
||||
PLACEHOLDERS = ("invitee_name", "inviter_name", "invite_link", "invite_button", "logo")
|
||||
_PLACEHOLDER = re.compile(r"\{\{\s*([a-z_]+)\s*\}\}")
|
||||
_DATA_SRC = re.compile(r"""(\bsrc\s*=\s*)(["'])(data:image/[^"']+)\2""", re.IGNORECASE)
|
||||
|
||||
DEFAULT_SUBJECT = "{{inviter_name}} invites you to Ultimo Email"
|
||||
_BODY = "font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#1f2937;line-height:1.5"
|
||||
_MUTED = "font-size:13px;color:#6b7280"
|
||||
_BUTTON = (
|
||||
"display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;"
|
||||
"padding:10px 18px;border-radius:8px;font-weight:bold"
|
||||
)
|
||||
DEFAULT_TEMPLATE = f"""<!doctype html>
|
||||
<html><body style="{_BODY}">
|
||||
{{{{logo}}}}
|
||||
<p>Hi {{{{invitee_name}}}},</p>
|
||||
<p><b>{{{{inviter_name}}}}</b> invites you to Ultimo Email — private email,
|
||||
calendar and contacts in one place.</p>
|
||||
<p>{{{{invite_button}}}}</p>
|
||||
<p style="{_MUTED}">Or paste this address into your browser:<br>{{{{invite_link}}}}</p>
|
||||
<p style="{_MUTED}">The invitation works once and expires in 14 days. If you
|
||||
weren't expecting it, you can ignore this email.</p>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
class InvitationError(Exception):
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderedMail:
|
||||
subject: str
|
||||
text: str
|
||||
html: str
|
||||
inline: list[InlinePart]
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def sender_address(row: AppSettings) -> str:
|
||||
"""The invitation's From. Empty: the system-mail From address, the one the
|
||||
SMTP account is known to be allowed to send as."""
|
||||
return row.invite_from.strip() or row.smtp_from
|
||||
|
||||
|
||||
def template_problem(html: str) -> str | None:
|
||||
"""Why ``html`` cannot be used as the invitation template, or None."""
|
||||
if len(html.encode()) > MAX_TEMPLATE_BYTES:
|
||||
return f"the template is larger than {MAX_TEMPLATE_BYTES // 1024} KB"
|
||||
used = set(_PLACEHOLDER.findall(html))
|
||||
unknown = sorted(used - set(PLACEHOLDERS))
|
||||
if unknown:
|
||||
return "unknown placeholder: " + ", ".join("{{" + u + "}}" for u in unknown)
|
||||
if not used & {"invite_link", "invite_button"}:
|
||||
return "the template must contain {{invite_link}} or {{invite_button}}"
|
||||
return None
|
||||
|
||||
|
||||
def render(row: AppSettings, *, invitee_name: str, inviter_name: str, link: str) -> RenderedMail:
|
||||
"""The invitation email for one friend, from the configured template."""
|
||||
template = row.invite_template or DEFAULT_TEMPLATE
|
||||
subject_template = row.invite_subject or DEFAULT_SUBJECT
|
||||
esc_link = html_lib.escape(link, quote=True)
|
||||
html_values = {
|
||||
"invitee_name": html_lib.escape(invitee_name),
|
||||
"inviter_name": html_lib.escape(inviter_name),
|
||||
"invite_link": f'<a href="{esc_link}">{esc_link}</a>',
|
||||
"invite_button": f'<a href="{esc_link}" style="{_BUTTON}">Accept the invitation</a>',
|
||||
"logo": LOGO_HTML,
|
||||
}
|
||||
text_values = {
|
||||
"invitee_name": invitee_name,
|
||||
"inviter_name": inviter_name,
|
||||
"invite_link": link,
|
||||
"invite_button": link,
|
||||
"logo": "",
|
||||
}
|
||||
|
||||
def fill(source: str, values: dict[str, str]) -> str:
|
||||
return _PLACEHOLDER.sub(lambda m: values.get(m.group(1), m.group(0)), source)
|
||||
|
||||
# An image written into the template itself (data:) goes as an inline part:
|
||||
# Gmail does not show data: images.
|
||||
inline: list[InlinePart] = []
|
||||
|
||||
def to_cid(match: re.Match[str]) -> str:
|
||||
part = data_image(match.group(3), get_settings().app_domain)
|
||||
if part is None:
|
||||
return match.group(0)
|
||||
inline.append(part)
|
||||
return f"{match.group(1)}{match.group(2)}cid:{part.cid}{match.group(2)}"
|
||||
|
||||
html = _DATA_SRC.sub(to_cid, fill(template, html_values))
|
||||
text = fill(template, text_values)
|
||||
# Keep the paragraphs and line breaks the HTML shows once its tags are gone.
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"</(?:p|div|h[1-6]|li|tr|table)\s*>", "\n\n", text, flags=re.IGNORECASE)
|
||||
text = nh3.clean(text, tags=set(), attributes={}, strip_comments=True)
|
||||
text = html_lib.unescape(re.sub(r"\n\s*\n\s*", "\n\n", text)).strip() + "\n"
|
||||
subject = " ".join(fill(subject_template, text_values).split())
|
||||
return RenderedMail(subject=subject, text=text, html=html, inline=inline)
|
||||
|
||||
|
||||
async def inviter_name(db: AsyncSession, user: User) -> str:
|
||||
"""How the friend is told who invited them."""
|
||||
names = (
|
||||
await db.execute(
|
||||
select(Account.email, Account.display_name).where(Account.user_id == user.id)
|
||||
)
|
||||
).all()
|
||||
own = next((n for e, n in names if e.lower() == user.handle.lower() and n), None)
|
||||
return own or next((n for _, n in names if n), None) or user.handle
|
||||
|
||||
|
||||
def _counts(now: dt.datetime): # type: ignore[no-untyped-def]
|
||||
# Used, or still valid: an invitation that expired unused is given back.
|
||||
return or_(Invitation.used_at.isnot(None), Invitation.expires_at > now)
|
||||
|
||||
|
||||
async def remaining(db: AsyncSession, user_id: uuid.UUID) -> int:
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
taken = await db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Invitation)
|
||||
.where(Invitation.inviter_id == user_id, _counts(now))
|
||||
)
|
||||
return max(0, PER_USER - int(taken or 0))
|
||||
|
||||
|
||||
async def sent(db: AsyncSession, user_id: uuid.UUID) -> list[Invitation]:
|
||||
return list(
|
||||
await db.scalars(
|
||||
select(Invitation)
|
||||
.where(Invitation.inviter_id == user_id)
|
||||
.order_by(Invitation.created_at.desc())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def create(db: AsyncSession, user: User, *, name: str, email: str) -> tuple[Invitation, str]:
|
||||
"""A new invitation from ``user`` (not committed): the row and the token.
|
||||
Raises :class:`InvitationError` when the user has none left."""
|
||||
# One at a time per user, so two quick clicks cannot both take the last one.
|
||||
await db.execute(select(User.id).where(User.id == user.id).with_for_update())
|
||||
if await remaining(db, user.id) <= 0:
|
||||
raise InvitationError("no_invitations_left", "you have no invitations left")
|
||||
token = secrets.token_urlsafe(32)
|
||||
invitation = Invitation(
|
||||
inviter_id=user.id,
|
||||
token_hash=hash_token(token),
|
||||
invitee_name=name,
|
||||
invitee_email=email,
|
||||
expires_at=dt.datetime.now(dt.UTC) + VALID_FOR,
|
||||
)
|
||||
db.add(invitation)
|
||||
await db.flush()
|
||||
return invitation, token
|
||||
|
||||
|
||||
async def find_valid(db: AsyncSession, token: str) -> Invitation | None:
|
||||
"""The unused, unexpired invitation behind ``token``, without using it."""
|
||||
invitation: Invitation | None = await db.scalar(
|
||||
select(Invitation).where(
|
||||
Invitation.token_hash == hash_token(token),
|
||||
Invitation.used_at.is_(None),
|
||||
Invitation.expires_at > dt.datetime.now(dt.UTC),
|
||||
)
|
||||
)
|
||||
return invitation
|
||||
|
||||
|
||||
async def claim(db: AsyncSession, token: str) -> uuid.UUID | None:
|
||||
"""Use the invitation up (in the caller's transaction): its id, or None if
|
||||
it is unknown, used or expired. Atomic — two sign-ups with one link cannot
|
||||
both get it."""
|
||||
return await db.scalar(
|
||||
update(Invitation)
|
||||
.where(
|
||||
Invitation.token_hash == hash_token(token),
|
||||
Invitation.used_at.is_(None),
|
||||
Invitation.expires_at > dt.datetime.now(dt.UTC),
|
||||
)
|
||||
.values(used_at=func.now())
|
||||
.returning(Invitation.id)
|
||||
)
|
||||
|
||||
|
||||
async def record_user(db: AsyncSession, invitation_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
await db.execute(
|
||||
update(Invitation).where(Invitation.id == invitation_id).values(used_by=user_id)
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Outgoing system mail (SPEC §6.1): mail the server sends on its own behalf.
|
||||
|
||||
Today that is only the password-reset link. It goes out through the SMTP
|
||||
Password-reset links, and invitations users send their friends
|
||||
(app/identity/invitations.py). It goes out through the SMTP
|
||||
account the administrator configured in the admin panel (``app_settings``),
|
||||
never through a user's own mail account — the whole point is that the user
|
||||
cannot sign in. The administrator's server is trusted, so unlike user mail
|
||||
@@ -10,19 +11,26 @@ on the LAN).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import html as html_lib
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, make_msgid
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
from app.config import smtp_helo_name
|
||||
from app.models import AppSettings
|
||||
from app.security.crypto import decrypt_secret
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.outbox.inline_images import InlinePart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SENDER_NAME = "Ultimo Email"
|
||||
@@ -33,9 +41,9 @@ _TIMEOUT = 20.0
|
||||
# because mail clients (Gmail first) do not display SVG. Rendered at twice the
|
||||
# size it is shown at, for sharp high-DPI screens.
|
||||
_LOGO_FILE = Path(__file__).parent / "assets" / "ultimo-logo.png"
|
||||
_LOGO_CID = "ultimo-logo@ultimo.email"
|
||||
LOGO_CID = "ultimo-logo@ultimo.email"
|
||||
LOGO_HTML = (
|
||||
f'<p style="text-align:center;margin:0 0 24px"><img src="cid:{_LOGO_CID}" '
|
||||
f'<p style="text-align:center;margin:0 0 24px"><img src="cid:{LOGO_CID}" '
|
||||
'width="180" height="47" alt="Ultimo Email" style="display:inline-block;border:0"></p>'
|
||||
)
|
||||
|
||||
@@ -45,6 +53,10 @@ def _logo() -> bytes:
|
||||
return _LOGO_FILE.read_bytes()
|
||||
|
||||
|
||||
# For previews in the admin panel, which show the HTML without message parts.
|
||||
LOGO_DATA_URL = "data:image/png;base64," + base64.b64encode(_LOGO_FILE.read_bytes()).decode()
|
||||
|
||||
|
||||
class MailerError(Exception):
|
||||
"""System mail is not configured, or the SMTP server refused the message."""
|
||||
|
||||
@@ -76,19 +88,49 @@ def mailer_config(row: AppSettings) -> MailerConfig | None:
|
||||
)
|
||||
|
||||
|
||||
async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None:
|
||||
async def send_mail(
|
||||
cfg: MailerConfig,
|
||||
to: str,
|
||||
subject: str,
|
||||
text: str,
|
||||
html: str,
|
||||
*,
|
||||
sender: str | None = None,
|
||||
reply_to: str | None = None,
|
||||
inline: Sequence[InlinePart] = (),
|
||||
) -> None:
|
||||
"""Send one system message. ``sender`` replaces the configured From address
|
||||
(the SMTP account must be allowed to send as it); ``inline`` are images the
|
||||
HTML names by ``cid:``."""
|
||||
msg = EmailMessage()
|
||||
msg["From"] = formataddr((SENDER_NAME, cfg.sender))
|
||||
msg["From"] = formataddr((SENDER_NAME, sender or cfg.sender))
|
||||
msg["To"] = to
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
msg["Subject"] = subject
|
||||
# RFC 5322 requires it, and mail without one scores as spam (Gmail).
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg["Message-ID"] = make_msgid(domain=cfg.sender.rsplit("@", 1)[-1])
|
||||
msg["Auto-Submitted"] = "auto-generated" # RFC 3834: do not auto-reply to this
|
||||
msg.set_content(text)
|
||||
msg.add_alternative(html, subtype="html")
|
||||
if f"cid:{_LOGO_CID}" in html:
|
||||
if f"cid:{LOGO_CID}" in html:
|
||||
html_part = msg.get_body(("html",))
|
||||
if isinstance(html_part, EmailMessage):
|
||||
html_part.add_related(_logo(), maintype="image", subtype="png", cid=f"<{_LOGO_CID}>")
|
||||
html_part.add_related(_logo(), maintype="image", subtype="png", cid=f"<{LOGO_CID}>")
|
||||
if inline:
|
||||
html_part = msg.get_body(("html",))
|
||||
assert isinstance(html_part, EmailMessage)
|
||||
for part in inline:
|
||||
maintype, _, subtype = part.mime_type.partition("/")
|
||||
html_part.add_related(
|
||||
part.data,
|
||||
maintype=maintype,
|
||||
subtype=subtype,
|
||||
cid=f"<{part.cid}>",
|
||||
filename=part.filename,
|
||||
disposition="inline",
|
||||
)
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
@@ -99,6 +141,7 @@ async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: s
|
||||
use_tls=cfg.security == "ssl",
|
||||
start_tls=cfg.security == "starttls",
|
||||
timeout=_TIMEOUT,
|
||||
local_hostname=smtp_helo_name(),
|
||||
)
|
||||
except (aiosmtplib.SMTPException, OSError) as exc:
|
||||
raise MailerError(str(exc) or type(exc).__name__) from exc
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -97,7 +97,12 @@ async def registration_status(db: AsyncSession) -> RegistrationStatus:
|
||||
|
||||
|
||||
async def register_user(
|
||||
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None
|
||||
db: AsyncSession,
|
||||
*,
|
||||
localpart: str,
|
||||
password: str,
|
||||
recovery_email: str | None,
|
||||
invited: bool = False,
|
||||
) -> User:
|
||||
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness.
|
||||
|
||||
@@ -114,7 +119,7 @@ async def register_user(
|
||||
|
||||
settings = await app_settings(db, for_update=True)
|
||||
bootstrap = not await _has_users(db)
|
||||
if not bootstrap and not settings.registration_open:
|
||||
if not bootstrap and not settings.registration_open and not invited:
|
||||
raise RegistrationError("registration_closed", "registration is closed on this server")
|
||||
|
||||
user = User(
|
||||
|
||||
Reference in New Issue
Block a user