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
@@ -14,6 +14,7 @@ import aiosmtplib
from app.accounts.ssrf import SsrfError, assert_host_is_public
from app.accounts.types import ServerConfig
from app.config import smtp_helo_name
from app.providers.base import ProviderAuthError, ProviderConnectError
from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig
@@ -53,6 +54,7 @@ async def _test_smtp(config: ServerConfig, username: str, password: str) -> None
use_tls=config.tls == "ssl",
start_tls=config.tls == "starttls",
timeout=_SMTP_TIMEOUT,
local_hostname=smtp_helo_name(),
)
try:
await smtp.connect()
+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)
+9
View File
@@ -74,6 +74,9 @@ class Settings(BaseSettings):
# New passwords are checked against known data breaches (Have I Been Pwned's
# k-anonymity range API: only 5 hex digits of a SHA-1 leave the server).
password_breach_check: bool = Field(default=True, alias="PASSWORD_BREACH_CHECK")
# The name the webmail gives itself when it connects to a mail server to
# send (SMTP EHLO). Empty: APP_DOMAIN — not the container's random name.
smtp_helo_name: str = Field(default="", alias="SMTP_HELO_NAME")
# Auto-purge Trash/Spam messages older than this many days (0 disables).
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
@@ -109,3 +112,9 @@ class Settings(BaseSettings):
def get_settings() -> Settings:
"""Return the process-wide settings singleton."""
return Settings() # values are sourced from the environment
def smtp_helo_name() -> str:
"""The name to introduce ourselves with to SMTP servers (EHLO)."""
settings = get_settings()
return settings.smtp_helo_name.strip() or settings.app_domain
+240
View File
@@ -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)
)
+51 -8
View File
@@ -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
+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
+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)
+7 -2
View File
@@ -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(
+28
View File
@@ -87,6 +87,13 @@ class AppSettings(Base):
# take it (base64url, uncompressed P-256 point).
vapid_public: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
vapid_private_ct: Mapped[bytes | None] = mapped_column(BYTEA)
# Users may invite friends (app/identity/invitations.py); the invitation
# email's From address (empty: postmaster@APP_DOMAIN), subject and HTML
# template (empty: the built-in ones).
invitations_enabled: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
invite_from: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
invite_subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
invite_template: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
updated_at: Mapped[dt.datetime] = _now()
@@ -376,6 +383,27 @@ class UserAppPassword(Base):
server_id: Mapped[str | None] = mapped_column(Text)
class Invitation(Base):
"""A single-use link that lets one person sign up, sent by a user."""
__tablename__ = "invitations"
id: Mapped[uuid.UUID] = _uuid_pk()
inviter_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# sha256 of the token in the link; the token itself is only in the email.
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
invitee_name: Mapped[str] = mapped_column(Text, nullable=False)
invitee_email: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[dt.datetime] = _now()
expires_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
used_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
class PushSubscription(Base):
"""A browser or installed app to tell about new mail (Web Push)."""
+52 -4
View File
@@ -7,11 +7,19 @@ Sent as is, that URL means nothing to the recipient (and to anyone else,
including ourselves once the original is gone). Before sending, each such image
is put back into the message as an inline part with a new Content-ID — as any
mail program does — and one that can no longer be had is left out.
An image written into the HTML itself (``data:image/…;base64,…`` — a signature
logo pasted into the editor) goes the same way: Gmail and other mail programs
do not show ``data:`` images in mail they receive, so each becomes an inline
part too (one per distinct image).
"""
from __future__ import annotations
import asyncio
import base64
import binascii
import hashlib
import re
import uuid
from dataclasses import dataclass
@@ -26,6 +34,9 @@ from app.indexer import rawstore
from app.indexer.sanitize import INTERNAL_CID
from app.models import Attachment, Message
_DATA_IMG = re.compile(r"^data:(image/[a-z0-9.+-]+);base64,(.*)$", re.IGNORECASE | re.DOTALL)
_EXT = {"jpeg": "jpg", "svg+xml": "svg"}
_IMG = re.compile(r"<img\b[^>]*?\bsrc\s*=\s*([\"'])(.*?)\1[^>]*>", re.IGNORECASE | re.DOTALL)
@@ -37,6 +48,23 @@ class InlinePart:
data: bytes
def data_image(src: str, domain: str) -> InlinePart | None:
"""The inline part for a ``data:image/…;base64`` URL, or None if it is not one."""
m = _DATA_IMG.match(src.strip())
if m is None:
return None
try:
data = base64.b64decode(re.sub(r"\s+", "", m.group(2)), validate=True)
except (binascii.Error, ValueError):
return None
if not data:
return None
mime_type = m.group(1).lower()
subtype = mime_type.split("/", 1)[1]
name = f"image.{_EXT.get(subtype, subtype)}"
return InlinePart(make_msgid(domain=domain)[1:-1], mime_type, name, data)
def internal_cid(src: str) -> tuple[uuid.UUID, str] | None:
"""The (message id, content id) an image URL of this server points at."""
value = src.strip()
@@ -57,9 +85,20 @@ async def embed(
"""``html`` with this server's image URLs turned into ``cid:`` references,
and the parts to send them in."""
parts: dict[tuple[uuid.UUID, str], InlinePart | None] = {}
# Images written into the HTML, by content: the same logo twice is one part.
data_parts: dict[str, InlinePart | None] = {}
def data_key(src: str) -> str:
return hashlib.sha256(src.strip().encode()).hexdigest()
for match in _IMG.finditer(html):
target = internal_cid(match.group(2))
src = match.group(2)
if src.strip().lower().startswith("data:"):
key = data_key(src)
if key not in data_parts:
data_parts[key] = data_image(src, domain)
continue
target = internal_cid(src)
if target is None or target in parts:
continue
message_id, content_id = target
@@ -90,16 +129,25 @@ async def embed(
parts[target] = part
def replace(match: re.Match[str]) -> str:
quote = match.group(1)
src = match.group(2)
if src.strip().lower().startswith("data:"):
data_part = data_parts.get(data_key(src))
if data_part is None:
return match.group(0) # not an image we can carry: left as it was
return match.group(0).replace(
f"src={quote}{src}{quote}", f"src={quote}cid:{data_part.cid}{quote}", 1
)
target = internal_cid(match.group(2))
if target is None:
return match.group(0)
part = parts.get(target)
if part is None:
return "" # gone: no broken image for the recipient
quote = match.group(1)
return match.group(0).replace(
f"src={quote}{match.group(2)}{quote}", f"src={quote}cid:{part.cid}{quote}", 1
)
out = _IMG.sub(replace, html) if parts else html
return out, [p for p in parts.values() if p is not None]
out = _IMG.sub(replace, html) if parts or data_parts else html
quoted = [p for p in parts.values() if p is not None]
return out, quoted + [p for p in data_parts.values() if p is not None]
+3 -1
View File
@@ -20,7 +20,7 @@ import aiosmtplib
from aioimaplib import aioimaplib
from app.concurrency import loop_semaphore
from app.config import get_settings
from app.config import get_settings, smtp_helo_name
from app.providers.base import (
FlagChange,
MailboxDelta,
@@ -579,6 +579,8 @@ class ImapSmtpProvider(MailProvider):
start_tls=start_tls,
tls_context=cfg.tls_context,
timeout=_CONNECT_TIMEOUT,
# Our own name, not the container's random host name.
local_hostname=smtp_helo_name(),
)
except aiosmtplib.SMTPException as exc:
raise _smtp_error(exc) from exc
@@ -0,0 +1,63 @@
"""invitations: users invite friends, even while sign-up is closed
An administrator switch lets every user send a few single-use invitation links
by email; a link opens sign-up for the person it was sent to even when public
registration is off. The email is built from a template the administrator
uploads (or the built-in one) and sent from the instance's own address.
Revision ID: 0027
Revises: 0026
Create Date: 2026-10-14
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0027"
down_revision: str | None = "0026"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE app_settings
ADD COLUMN invitations_enabled boolean NOT NULL DEFAULT false,
ADD COLUMN invite_from text NOT NULL DEFAULT '',
ADD COLUMN invite_subject text NOT NULL DEFAULT '',
ADD COLUMN invite_template text NOT NULL DEFAULT ''
"""
)
op.execute(
"""
CREATE TABLE invitations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inviter_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE,
invitee_name text NOT NULL,
invitee_email text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
used_at timestamptz,
used_by uuid REFERENCES users(id) ON DELETE SET NULL
)
"""
)
op.execute("CREATE INDEX ix_invitations_inviter ON invitations (inviter_id)")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS invitations")
op.execute(
"""
ALTER TABLE app_settings
DROP COLUMN IF EXISTS invitations_enabled,
DROP COLUMN IF EXISTS invite_from,
DROP COLUMN IF EXISTS invite_subject,
DROP COLUMN IF EXISTS invite_template
"""
)
+349
View File
@@ -0,0 +1,349 @@
"""Invite a friend: each user sends up to two single-use sign-up links, which
work even while registration is closed (app/identity/invitations.py).
Runs against the dev database; the settings row is put back as it was and every
user made here is removed on the way out. Mail is captured, never sent.
"""
from __future__ import annotations
import base64
import datetime as dt
import re
import uuid
from collections.abc import AsyncIterator
from typing import Any
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import invitations as invitations_api
from app.config import get_settings
from app.identity import invitations
from app.identity.mailer import MailerError
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import Account, AppSettings, Invitation, User
CSRF = "csrf-token"
_SETTINGS = (
"registration_open",
"invitations_enabled",
"smtp_host",
"smtp_from",
"public_url",
"invite_from",
"invite_subject",
"invite_template",
)
class World:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.created: list[uuid.UUID] = []
self.handles: list[str] = []
self.sent: list[dict[str, Any]] = []
async def user(self, *, admin: bool = False, name: str = "") -> User:
handle = f"inv{uuid.uuid4().hex[:8]}@test.local"
user = User(handle=handle, password_hash=hash_password("pw"), is_admin=admin)
self.session.add(user)
await self.session.flush()
if name:
self.session.add(
Account(
user_id=user.id,
email=handle,
display_name=name,
imap_config={},
smtp_config={},
secret_ct=b"x",
discovery_src="manual",
)
)
await self.session.commit()
self.created.append(user.id)
return user
async def client(self, user: User | None = None) -> httpx.AsyncClient:
cookies = {"csrf": CSRF}
if user is not None:
cookies["session"] = await create_session(user.id, "pytest")
return httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies=cookies,
headers={"X-CSRF": CSRF},
)
async def settings(self, **values: Any) -> None:
await self.session.execute(update(AppSettings).where(AppSettings.id == 1).values(**values))
await self.session.commit()
@pytest_asyncio.fixture
async def world(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[World]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
session = async_sessionmaker(engine, expire_on_commit=False)()
row = await session.scalar(select(AppSettings).where(AppSettings.id == 1))
assert row is not None
saved = {name: getattr(row, name) for name in _SETTINGS}
w = World(session)
async def capture(cfg, to, subject, text, html, **kw): # type: ignore[no-untyped-def]
w.sent.append({"to": to, "subject": subject, "text": text, "html": html, **kw})
monkeypatch.setattr(invitations_api, "send_mail", capture)
await w.settings(
registration_open=False,
invitations_enabled=True,
smtp_host="smtp.example.com",
smtp_from="system@example.com",
public_url="https://mail.example.com",
invite_from="",
invite_subject="",
invite_template="",
)
try:
yield w
finally:
await session.rollback()
await w.settings(**saved)
await session.execute(delete(User).where(User.id.in_(w.created)))
if w.handles:
await session.execute(delete(User).where(User.handle.in_(w.handles)))
await session.commit()
await session.close()
await engine.dispose()
def _token(mail: dict[str, Any]) -> str:
m = re.search(r"#invite=([A-Za-z0-9_-]+)", mail["text"])
assert m is not None, mail["text"]
return m.group(1)
async def _register(w: World, token: str | None) -> httpx.Response:
localpart = f"new{uuid.uuid4().hex[:8]}"
w.handles.append(f"{localpart}@{get_settings().app_domain}")
body: dict[str, Any] = {"localpart": localpart, "password": "correct horse battery"}
if token is not None:
body["invite"] = token
async with await w.client() as anon:
return await anon.post("/api/auth/register", json=body)
async def test_two_invitations_then_none(world: World) -> None:
inviter = await world.user(name="Jan Kowalski")
async with await world.client(inviter) as c:
state = (await c.get("/api/invitations")).json()
assert state["available"] is True and state["remaining"] == 2
for i, name in enumerate(["Anna", "Piotr"]):
resp = await c.post(
"/api/invitations", json={"name": name, "email": f"f{i}@example.org"}
)
assert resp.status_code == 200, resp.text
assert resp.json()["remaining"] == 0
assert [s["invitee_name"] for s in resp.json()["sent"]] == ["Piotr", "Anna"]
third = await c.post("/api/invitations", json={"name": "Ola", "email": "o@example.org"})
assert third.status_code == 409
assert third.json()["error"]["code"] == "no_invitations_left"
mail = world.sent[0]
assert mail["to"] == "f0@example.org"
assert mail["sender"] == "system@example.com"
assert mail["reply_to"] == inviter.handle
assert mail["subject"] == "Jan Kowalski invites you to Ultimo Email"
assert "Hi Anna" in mail["html"] and "https://mail.example.com/register#invite=" in mail["html"]
async def test_an_invitation_signs_up_once_while_registration_is_closed(world: World) -> None:
inviter = await world.user()
async with await world.client(inviter) as c:
await c.post("/api/invitations", json={"name": "Anna", "email": "anna@example.org"})
token = _token(world.sent[0])
closed = await _register(world, None)
assert closed.status_code == 403 # registration itself stays closed
async with await world.client() as anon:
check = (await anon.post("/api/auth/invitation/check", json={"token": token})).json()
assert check == {"valid": True, "invitee_name": "Anna", "inviter_name": inviter.handle}
first = await _register(world, token)
assert first.status_code == 200, first.text
again = await _register(world, token)
assert again.status_code == 400
assert again.json()["error"]["code"] == "invalid_invitation"
row = await world.session.scalar(select(Invitation).where(Invitation.inviter_id == inviter.id))
assert row is not None and row.used_at is not None and row.used_by is not None
async def test_a_failed_sign_up_keeps_the_invitation(world: World) -> None:
inviter = await world.user()
async with await world.client(inviter) as c:
await c.post("/api/invitations", json={"name": "Anna", "email": "anna@example.org"})
token = _token(world.sent[0])
weak = await world.client()
async with weak:
resp = await weak.post(
"/api/auth/register",
json={"localpart": "taken" + uuid.uuid4().hex[:6], "password": "password123"},
)
assert resp.status_code == 400 # refused password: nothing used
taken = await _register(world, token)
assert taken.status_code == 200
# Already used now; but a sign-up that failed on a taken address must not use it:
second_inviter = await world.user()
async with await world.client(second_inviter) as c:
await c.post("/api/invitations", json={"name": "B", "email": "b@example.org"})
token2 = _token(world.sent[-1])
async with await world.client() as anon:
dup = await anon.post(
"/api/auth/register",
json={
"localpart": world.handles[-1].split("@")[0],
"password": "correct horse battery",
"invite": token2,
},
)
assert dup.status_code == 409
assert (await invitations.find_valid(world.session, token2)) is not None
async def test_switched_off_means_no_sending_and_no_signing_up(world: World) -> None:
inviter = await world.user()
async with await world.client(inviter) as c:
await c.post("/api/invitations", json={"name": "Anna", "email": "anna@example.org"})
token = _token(world.sent[0])
await world.settings(invitations_enabled=False)
assert (await c.get("/api/invitations")).json()["available"] is False
refused = await c.post("/api/invitations", json={"name": "B", "email": "b@example.org"})
assert refused.status_code == 403
assert (await _register(world, token)).status_code == 400
async def test_a_mail_that_could_not_go_out_is_given_back(
world: World, monkeypatch: pytest.MonkeyPatch
) -> None:
async def failing(*args, **kwargs): # type: ignore[no-untyped-def]
raise MailerError("relay refused")
monkeypatch.setattr(invitations_api, "send_mail", failing)
inviter = await world.user()
async with await world.client(inviter) as c:
resp = await c.post("/api/invitations", json={"name": "Anna", "email": "a@example.org"})
assert resp.status_code == 502
assert (await c.get("/api/invitations")).json()["remaining"] == 2
async def test_an_expired_unused_invitation_is_given_back(world: World) -> None:
inviter = await world.user()
async with await world.client(inviter) as c:
await c.post("/api/invitations", json={"name": "Anna", "email": "a@example.org"})
await world.session.execute(
update(Invitation)
.where(Invitation.inviter_id == inviter.id)
.values(expires_at=dt.datetime.now(dt.UTC) - dt.timedelta(minutes=1))
)
await world.session.commit()
state = (await c.get("/api/invitations")).json()
assert state["remaining"] == 2 and state["sent"][0]["expired"] is True
assert (await _register(world, _token(world.sent[0]))).status_code == 400
async def test_bad_input_is_refused(world: World) -> None:
inviter = await world.user()
async with await world.client(inviter) as c:
bad = await c.post("/api/invitations", json={"name": "A", "email": "not-an-address"})
assert bad.status_code == 400
blank = await c.post("/api/invitations", json={"name": " ", "email": "a@example.org"})
assert blank.status_code == 400
# --- the email ------------------------------------------------------------------------
def test_names_are_escaped_and_placeholders_filled() -> None:
row = AppSettings(
invite_subject="{{inviter_name}} → {{invitee_name}}",
invite_template="<p>Hi {{ invitee_name }}</p><p>{{inviter_name}}</p>{{invite_button}}",
)
mail = invitations.render(
row, invitee_name="<b>Eve</b>", inviter_name='Bob "x"', link="https://m.example/r#invite=t"
)
assert "&lt;b&gt;Eve&lt;/b&gt;" in mail.html and "<b>Eve" not in mail.html
assert 'href="https://m.example/r#invite=t"' in mail.html
assert mail.subject == 'Bob "x" → <b>Eve</b>' # a header, not HTML
assert "https://m.example/r#invite=t" in mail.text and "<p>" not in mail.text
def test_template_images_travel_inside_the_mail() -> None:
png = base64.b64encode(b"\x89PNG\r\n\x1a\nfake").decode()
row = AppSettings(invite_template=f'<img src="data:image/png;base64,{png}">{{{{invite_link}}}}')
mail = invitations.render(row, invitee_name="A", inviter_name="B", link="https://x/y")
assert len(mail.inline) == 1 and "data:image" not in mail.html
assert f'src="cid:{mail.inline[0].cid}"' in mail.html
@pytest.mark.parametrize(
"html,ok",
[
("<p>{{invite_link}}</p>", True),
("<p>{{invite_button}}</p>", True),
("<p>Hello {{invitee_name}}</p>", False), # no way to accept
("<p>{{invite_link}} {{password}}</p>", False), # unknown placeholder
],
)
def test_template_checks(html: str, ok: bool) -> None:
assert (invitations.template_problem(html) is None) is ok
async def test_admin_uploads_previews_and_resets_the_template(world: World) -> None:
admin = await world.user(admin=True)
async with await world.client(admin) as c:
bad = await c.put("/api/admin/invitations/template", json={"html": "<p>hi</p>"})
assert bad.status_code == 400
saved = await c.put(
"/api/admin/invitations/template",
json={
"html": "<p>Dear {{invitee_name}}, {{invite_button}}</p>",
"subject": "Join {{inviter_name}}",
"from_address": "postmaster@example.org",
},
)
assert saved.status_code == 200
assert saved.json()["from_address"] == "postmaster@example.org"
preview = await c.post(
"/api/admin/invitations/preview",
json={"subject": saved.json()["subject"], "html": saved.json()["html"]},
)
assert preview.json()["subject"] == "Join Jan Kowalski"
assert "Dear Anna Nowak" in preview.json()["html"]
reset = await c.put(
"/api/admin/invitations/template",
json={"html": "", "subject": "", "from_address": ""},
)
assert reset.json()["html"] == "" and reset.json()["default_html"]
user = await world.user()
async with await world.client(user) as c:
assert (await c.get("/api/admin/invitations/template")).status_code == 403
async def test_admin_switch(world: World) -> None:
admin = await world.user(admin=True)
async with await world.client(admin) as c:
off = await c.put("/api/admin/settings", json={"invitations_enabled": False})
assert off.json()["invitations_enabled"] is False
on = await c.put("/api/admin/settings", json={"invitations_enabled": True})
assert on.json()["invitations_enabled"] is True
@@ -137,7 +137,11 @@ async def test_register_is_refused_while_closed(
status = await client.get("/api/auth/registration")
assert status.status_code == 200
assert status.json() == {"open": False, "bootstrap": False}
assert status.json() == {
"open": False,
"bootstrap": False,
"domain": get_settings().app_domain,
}
refused = await client.post(
"/api/auth/register", json={"localpart": _localpart(), "password": PASSWORD}
@@ -131,3 +131,41 @@ def test_links_already_sent_load_from_here() -> None:
result = sanitize_html(f'<img src="{src}">', uuid.uuid4(), allow_remote_images=True)
assert f'src="/api/messages/{mid}/cid/logo@x"' in result.html, src
assert not result.has_remote_images
async def test_images_written_into_the_html_go_as_inline_parts() -> None:
"""A signature logo pasted into the editor is a data: URL, which Gmail does
not show in mail it receives: it is sent as an inline part instead, once
however often it appears."""
import base64
data_url = "data:image/png;base64," + base64.b64encode(LOGO).decode()
html = (
f'<p>Hi</p><img src="{data_url}" width="40"><p>--</p><img src="{data_url}">'
'<img src="data:text/html;base64,PHNjcmlwdD4=">' # not an image: left alone
)
out, parts = await inline_images.embed(None, uuid.uuid4(), html, "example.com") # type: ignore[arg-type]
assert len(parts) == 1
assert parts[0].data == LOGO and parts[0].mime_type == "image/png"
assert parts[0].filename == "image.png"
assert out.count(f'src="cid:{parts[0].cid}"') == 2
assert "data:image/png" not in out
assert 'width="40"' in out
account = SimpleNamespace(email="me@example.com", display_name="Me")
draft = SimpleNamespace(payload={"to": ["a@example.org"], "subject": "Sig", "html": html})
raw, _, _, _ = build_mime(account, draft, None, [], html=out, inline=parts) # type: ignore[arg-type]
msg = email.message_from_bytes(raw, policy=policy.default)
images = [p for p in msg.walk() if p.get_content_type() == "image/png"]
assert len(images) == 1 and images[0].get_content() == LOGO
assert images[0]["Content-ID"] == f"<{parts[0].cid}>"
def test_smtp_servers_are_greeted_with_our_domain(monkeypatch) -> None: # type: ignore[no-untyped-def]
from app.config import smtp_helo_name
settings = get_settings()
monkeypatch.setattr(settings, "smtp_helo_name", "")
assert smtp_helo_name() == settings.app_domain
monkeypatch.setattr(settings, "smtp_helo_name", "mail.example.org")
assert smtp_helo_name() == "mail.example.org"
+2
View File
@@ -79,6 +79,8 @@ x-backend-env: &backend-env
SENDER_AVATARS_ENABLED: ${SENDER_AVATARS_ENABLED:-true}
# New passwords checked against Have I Been Pwned (k-anonymity; docs §5.2).
PASSWORD_BREACH_CHECK: ${PASSWORD_BREACH_CHECK:-true}
# Name given to SMTP servers when sending (EHLO). Empty: APP_DOMAIN.
SMTP_HELO_NAME: ${SMTP_HELO_NAME:-}
GOOGLE_OAUTH_ENABLED: ${GOOGLE_OAUTH_ENABLED:-false}
OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:-}
OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:-}
+107
View File
@@ -0,0 +1,107 @@
# DNS recommendations — ultimo.email and imaginary-pixels.com
Checked 2026-10-14, after the security assessment (DMARC/SPF finding) and the
first DMARC test mails. In priority order. See also `docs/mail-server-network.md`
for the hosted-mail (Stalwart) plan.
## State when checked
| | ultimo.email (DNS at home.pl) | imaginary-pixels.com (DNS at Cloudflare) |
|---|---|---|
| MX | `serwer2295308.home.pl` (46.242.246.139) | `_dc-mx…` → 46.242.246.139 (home.pl) |
| SPF | `v=spf1 a mx ~all` | `v=spf1 mx a ptr ~all` |
| DKIM | selector `dkim` present | selector `dkim` present, signing verified (PASS at Gmail) |
| DMARC | `p=none; rua=mailto:postmaster@ultimo.email; fo=1` | `p=none; rua=mailto:admin@imaginary-pixels.com` (added 2026-10-14) |
| CAA | none | none |
| DNSSEC | no | no |
| Other | wildcard `*.ultimo.email` → `ultimo.email`; SRV `_imaps`/`_submission` → `ultimo.email:993/587` (ports closed) | — |
Mail from the webmail goes out through home.pl's SMTP (46.242.246.139), which
both domains' SPF already allow through `mx`. The home IP 178.219.143.96 has
reverse DNS `pub-143.96.multiplay.pl` (cannot be changed by the ISP), so the
QNAP must not send directly to the internet.
## 1. Remove the mail-app SRV records for ultimo.email — now
```
_imaps._tcp.ultimo.email SRV 10 5 993 ultimo.email.
_submission._tcp.ultimo.email SRV 10 5 587 ultimo.email.
```
They tell Thunderbird, iPhones and other mail apps that ultimo.email's mail
server is `ultimo.email` on 993/587 — the home IP, where those ports are closed
(hosted mail is not live; mail is at home.pl). Apps that set themselves up from
them fail to connect. Delete both in the home.pl DNS panel; add them back when
Stalwart goes live.
## 2. Remove the wildcard `*.ultimo.email` — now
Every made-up name (e.g. `random-xyz123.ultimo.email`) resolves to the home IP
and lands on the Synology; lookups such as `_mta-sts.ultimo.email` and
`_smtp._tls.ultimo.email` come back with the SPF record. Delete the wildcard and
keep explicit records only for names in use: `ultimo.email` itself, plus `www`
if wanted (point 3). `mail.` and `autoconfig.` come back with the Stalwart
launch. Check first whether the wildcard was a home.pl default or deliberate.
## 3. `www.ultimo.email` — decide
It resolves but has no certificate, so `https://www.ultimo.email` shows a
browser error. Either drop it (it disappears with the wildcard), or keep it as
`CNAME ultimo.email`, add it to the DSM certificate and add a reverse-proxy
rule for it (HTTP rule with `X-Forwarded-Proto: http`, HTTPS rule like the
main one).
## 4. CAA for ultimo.email — recommended
Only the named certificate authority may issue certificates for the domain.
The Synology uses Let's Encrypt:
```
ultimo.email. CAA 0 issue "letsencrypt.org"
```
Before adding it, confirm with home.pl that they don't issue a certificate for
ultimo.email themselves; if they do, add their CA as another `issue` line or
skip CAA. Not needed for imaginary-pixels.com (Cloudflare manages its
certificates there).
## 5. SPF cleanups — after reviewing the DMARC reports
- **ultimo.email:** `v=spf1 mx -all`. The QNAP sends through home.pl (covered
by `mx`), so `a` — the home IP — is not needed. Change it together with the
move to `p=quarantine`.
- **imaginary-pixels.com:** `v=spf1 mx ~all`. `a` points at Cloudflare's web
servers (never send mail); `ptr` is deprecated. First check the reports for
any other legitimate sender (e.g. a website contact form).
## 6. DMARC staging — both domains
1. Collect aggregate reports for about 2 weeks (`p=none`). Make sure
`postmaster@ultimo.email` exists at home.pl, or its reports go nowhere.
2. When every legitimate sender passes: `p=quarantine`.
3. After another 2–4 weeks without legitimate failures: `p=reject`.
DMARC TXT records have a 5-minute TTL, but a missing record is cached for up
to 30 minutes (Cloudflare SOA minimum) — retest a while after creating one.
## 7. DNSSEC — optional, worth doing
Protects MX and other records against forged DNS answers.
- **imaginary-pixels.com:** Cloudflare → DNS → Settings → Enable DNSSEC, then
add the DS record it shows at the registrar.
- **ultimo.email:** check the home.pl panel for a DNSSEC option.
## Later — when hosted mail (Stalwart) goes live
- MX to `mail.ultimo.email`; SPF to match the real sending path (with the
home.pl relay, `mx`/home.pl keeps covering it).
- SRV (`_imaps`, `_submission`, `_submissions`) and `autoconfig` back, pointing
at `mail.ultimo.email`.
- Optional MTA-STS + TLS-RPT (needs a real `mta-sts.ultimo.email` with a
certificate — another reason to remove the wildcard now).
- `SMTP_HELO_NAME=mail.ultimo.email` in `docker/.env.prod` if Stalwart's
hostname should be the one announced.
Not fixable in DNS: the home IP's reverse DNS — avoided by always sending
through home.pl.
+255
View File
@@ -1,5 +1,6 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
@@ -163,6 +164,8 @@ function Panel({ handle }: { handle: string }) {
// The quota field is a draft until Save — unlike the toggle, you do not want a
// write per keystroke. `null` means "not edited yet, show the server's value".
const [quotaDraft, setQuotaDraft] = useState<string | null>(null);
// The invitation email settings open in a dialog of their own.
const [configuring, setConfiguring] = useState(false);
const save = useMutation({
mutationFn: api.saveAdminSettings,
@@ -216,6 +219,39 @@ function Panel({ handle }: { handle: string }) {
<p className="mt-1 text-xs text-app-faint">{t("admin.registrationHelp")}</p>
</div>
<div className="border-b border-app-border py-2">
<div className="flex items-center justify-between text-sm">
<span className="text-app-muted">{t("admin.invitations")}</span>
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
{([true, false] as const).map((value) => (
<button
key={String(value)}
disabled={save.isPending}
onClick={() => save.mutate({ invitations_enabled: value })}
className={`rounded px-3 py-1 disabled:opacity-50 ${
settings.data.invitations_enabled === value
? "bg-app-active font-medium text-app-fg"
: "text-app-muted hover:bg-app-hover"
}`}
>
{t(value ? "admin.invitationsOn" : "admin.invitationsOff")}
</button>
))}
</div>
</div>
<p className="mt-1 text-xs text-app-faint">{t("admin.invitationsHelp")}</p>
{settings.data.invitations_enabled && (
<button
type="button"
className="mt-2 rounded border border-app-border px-3 py-1 text-sm hover:bg-app-hover"
onClick={() => setConfiguring(true)}
>
{t("admin.inviteConfigure")}
</button>
)}
</div>
{configuring && <InvitationEmailDialog onClose={() => setConfiguring(false)} />}
<div className="border-b border-app-border py-2">
<div className="flex items-center justify-between text-sm">
<span className="text-app-muted">{t("admin.defaultQuota")}</span>
@@ -445,6 +481,225 @@ function SystemMail() {
);
}
/** "Configure" under Allow invitations: the invitation email settings in a dialog. */
function InvitationEmailDialog({ onClose }: { onClose: () => void }) {
const t = useT();
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 backdrop-blur-sm sm:items-center"
onClick={onClose}
>
<div
role="dialog"
aria-label={t("admin.inviteMail")}
className="relative my-4 max-h-[calc(100vh-2rem)] w-full max-w-2xl overflow-y-auto rounded-2xl bg-app-bg p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
aria-label={t("admin.close")}
className="absolute right-4 top-4 rounded px-2 py-0.5 text-xl leading-none text-app-muted hover:bg-app-hover hover:text-app-fg"
onClick={onClose}
>
×
</button>
<InvitationEmail />
</div>
</div>,
document.body,
);
}
/** The invitation email: sender, subject and an uploaded HTML template with
* placeholders; previewed with sample names, and sent as a test. */
function InvitationEmail() {
const t = useT();
const queryClient = useQueryClient();
const tpl = useQuery({ queryKey: ["adminInviteTemplate"], queryFn: api.adminInviteTemplate });
// Sender and subject are drafts until Save; null shows the server's value.
const [from, setFrom] = useState<string | null>(null);
const [subject, setSubject] = useState<string | null>(null);
const [testTo, setTestTo] = useState("");
const save = useMutation({
mutationFn: api.saveAdminInviteTemplate,
onSuccess: (data) => {
queryClient.setQueryData(["adminInviteTemplate"], data);
setFrom(null);
setSubject(null);
},
});
const test = useMutation({ mutationFn: () => api.testInvite(testTo.trim()) });
const data = tpl.data;
const preview = useQuery({
queryKey: ["adminInvitePreview", data?.subject, data?.html],
queryFn: () => api.previewInvite(data?.subject ?? "", data?.html ?? ""),
enabled: !!data,
});
if (!data) {
return tpl.error instanceof ApiError ? <p className="text-sm text-red-500">{tpl.error.message}</p> : null;
}
const custom = data.html !== "";
const dirty = (from !== null && from !== data.from_address) || (subject !== null && subject !== data.subject);
const field =
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none";
const label = "block space-y-1 text-sm";
const upload = async (file: File | undefined) => {
if (!file) return;
save.mutate({ html: await file.text() });
};
const download = () => {
const blob = new Blob([data.html || data.default_html], { type: "text/html" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = custom ? "invitation.html" : "invitation-default.html";
a.click();
URL.revokeObjectURL(a.href);
};
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold">{t("admin.inviteMail")}</h2>
<p className="text-xs text-app-faint">{t("admin.inviteMailHelp")}</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
save.mutate({
...(from !== null ? { from_address: from } : {}),
...(subject !== null ? { subject } : {}),
});
}}
>
<label className={label}>
<span className="text-app-muted">{t("admin.inviteFrom")}</span>
<input
className={field}
type="email"
value={from ?? data.from_address}
placeholder={data.default_from}
onChange={(e) => setFrom(e.target.value)}
/>
<span className="block text-xs text-app-faint">{t("admin.inviteFromHint")}</span>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.inviteSubject")}</span>
<input
className={field}
value={subject ?? data.subject}
placeholder={data.default_subject}
onChange={(e) => setSubject(e.target.value)}
/>
</label>
<div className="flex justify-end">
<button
type="submit"
className="rounded bg-app-accent px-3 py-1 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!dirty || save.isPending}
>
{save.isPending ? t("admin.saving") : t("admin.save")}
</button>
</div>
</form>
<div className="space-y-2 border-t border-app-border pt-3 text-sm">
<p className="text-app-muted">
{custom ? t("admin.inviteTemplateCustom") : t("admin.inviteTemplateDefault")}
</p>
<div className="flex flex-wrap gap-2">
<label className="cursor-pointer rounded border border-app-border px-3 py-1 hover:bg-app-hover">
{t("admin.inviteUpload")}
<input
type="file"
accept=".html,.htm,text/html"
className="hidden"
onChange={(e) => {
void upload(e.target.files?.[0]);
e.target.value = "";
}}
/>
</label>
<button type="button" className="rounded border border-app-border px-3 py-1 hover:bg-app-hover" onClick={download}>
{t("admin.inviteDownload")}
</button>
{custom && (
<button
type="button"
className="rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
disabled={save.isPending}
onClick={() => confirm(t("admin.inviteResetConfirm")) && save.mutate({ html: "" })}
>
{t("admin.inviteReset")}
</button>
)}
</div>
<p className="text-xs text-app-faint">
{t("admin.invitePlaceholders")}{" "}
{data.placeholders.map((p, i) => (
<span key={p}>
{i > 0 && ", "}
<code className="select-all rounded bg-app-hover px-1">{`{{${p}}}`}</code>
</span>
))}
</p>
<p className="text-xs text-app-faint">{t("admin.invitePlaceholdersHelp")}</p>
{save.error instanceof ApiError && <p className="text-xs text-red-500">{save.error.message}</p>}
</div>
{preview.data && (
<div className="space-y-1">
<p className="text-xs text-app-muted">
{t("admin.invitePreview")} <span className="font-medium text-app-fg">{preview.data.subject}</span>
</p>
{/* Sandboxed, script-less: the admin's own HTML, shown as mail. */}
<iframe
title={t("admin.invitePreview")}
sandbox=""
srcDoc={preview.data.html}
className="h-80 w-full rounded-lg border border-app-border bg-white"
/>
</div>
)}
<form
className="flex items-center gap-2 border-t border-app-border pt-3 text-sm"
onSubmit={(e) => {
e.preventDefault();
test.mutate();
}}
>
<span className="shrink-0 text-app-muted">{t("admin.testTo")}</span>
<input
className={field}
type="email"
value={testTo}
placeholder="you@example.com"
onChange={(e) => {
setTestTo(e.target.value);
test.reset();
}}
/>
<button
type="submit"
className="shrink-0 rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
disabled={!testTo.trim() || test.isPending || dirty}
>
{test.isPending ? t("auth.sending") : t("admin.sendTest")}
</button>
</form>
{test.isSuccess && <p className="text-xs text-emerald-600">{t("admin.inviteTestSent")}</p>}
{test.error instanceof ApiError && <p className="text-xs text-red-500">{test.error.message}</p>}
</section>
);
}
function formatBytes(bytes: number): string {
if (bytes >= GIB) return `${Number((bytes / GIB).toFixed(2))} GiB`;
return `${Number((bytes / 1024 ** 2).toFixed(1))} MiB`;
+53
View File
@@ -0,0 +1,53 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
/** The handle field of the sign-in and sign-up forms: the user types the
* localpart, the instance's domain sits after it and cannot be edited. Typing
* or pasting a full address keeps only the part before the "@". */
export function HandleInput({
value,
onChange,
placeholder,
disabled,
autoFocus,
}: {
value: string;
onChange: (localpart: string) => void;
placeholder?: string;
disabled?: boolean;
autoFocus?: boolean;
}) {
const status = useQuery({ queryKey: ["registration"], queryFn: api.registrationStatus });
const domain = status.data?.domain;
return (
<label
className={`flex w-full cursor-text items-center rounded-lg border border-app-border bg-app-surface px-3 py-2 focus-within:border-app-accent ${
disabled ? "opacity-50" : ""
}`}
>
{/* The input is as wide as its text (an invisible copy sizes the grid
cell), so the domain follows the typed name: demo@example.com. */}
<span className="inline-grid min-w-0 grid-cols-[minmax(0,auto)]">
<span aria-hidden className="invisible col-start-1 row-start-1 overflow-hidden whitespace-pre">
{value || placeholder}
</span>
<input
className="col-start-1 row-start-1 w-full min-w-[1ch] bg-transparent text-app-fg placeholder:text-app-faint focus:outline-none"
size={1}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value.split("@")[0])}
disabled={disabled}
autoFocus={autoFocus}
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
</span>
{domain && <span className="shrink-0 select-none whitespace-pre text-app-muted">@{domain}</span>}
</label>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { createPortal } from "react-dom";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
const inputClass =
"w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none";
/**
* "Invite a friend" in the sidebar: shown while the administrator has
* invitations on and this user has some left (two each); gone once they are
* all sent. Opens a dialog asking for the friend's name and address; the server
* emails them a single-use sign-up link (app/identity/invitations.py).
*/
export function InviteFriendEntry({ collapsed, onDone }: { collapsed: boolean; onDone?: () => void }) {
const t = useT();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const state = useQuery({ queryKey: ["invitations"], queryFn: api.invitations, staleTime: 60_000 });
const send = useMutation({
mutationFn: () => api.sendInvitation(name.trim(), email.trim()),
onSuccess: (data) => {
queryClient.setQueryData(["invitations"], data);
window.dispatchEvent(
new CustomEvent("webmail:toast", { detail: { text: t("invite.sent", { name: name.trim() }) } }),
);
close();
onDone?.();
},
});
const close = () => {
setOpen(false);
setName("");
setEmail("");
send.reset();
};
const data = state.data;
// The dialog stays up while the last invitation is on its way out.
if (!open && (!data?.available || data.remaining <= 0)) return null;
return (
<div className="px-2 pb-1 text-[15px]">
<button
type="button"
className={`wm-online-only flex w-full items-center gap-2.5 rounded-lg py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg ${
collapsed ? "justify-center px-0" : "px-3"
}`}
title={collapsed ? t("invite.entry") : undefined}
onClick={() => setOpen(true)}
>
<InviteGlyph />
{!collapsed && t("invite.entry")}
</button>
{open &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] backdrop-blur-sm sm:items-center"
onClick={close}
>
<form
role="dialog"
aria-label={t("invite.title")}
className="w-full max-w-sm space-y-3 rounded-2xl bg-app-elevated p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === "Escape" && close()}
onSubmit={(e) => {
e.preventDefault();
send.mutate();
}}
>
<div>
<h2 className="text-base font-semibold">{t("invite.title")}</h2>
<p className="mt-1 text-sm text-app-muted">
{t("invite.help", { count: data?.remaining ?? 0 })}
</p>
</div>
<label className="block space-y-1 text-sm">
<span className="text-app-muted">{t("invite.name")}</span>
<input
className={inputClass}
value={name}
maxLength={100}
autoFocus
required
onChange={(e) => setName(e.target.value)}
/>
</label>
<label className="block space-y-1 text-sm">
<span className="text-app-muted">{t("invite.email")}</span>
<input
className={inputClass}
type="email"
value={email}
maxLength={320}
required
placeholder="friend@example.com"
onChange={(e) => setEmail(e.target.value)}
/>
</label>
{send.error instanceof ApiError && <p className="text-sm text-red-500">{send.error.message}</p>}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
className="rounded-lg px-3 py-1.5 text-sm text-app-muted hover:bg-app-hover"
onClick={close}
>
{t("common.cancel")}
</button>
<button
type="submit"
className="rounded-lg bg-app-accent px-4 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={send.isPending || !name.trim() || !email.trim()}
>
{send.isPending ? t("invite.sending") : t("invite.send")}
</button>
</div>
</form>
</div>,
document.body,
)}
</div>
);
}
function InviteGlyph() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="9.5" cy="8.5" r="3.5" />
<path d="M3 19.5c.7-3.3 3.3-5.25 6.5-5.25s5.8 1.95 6.5 5.25" />
<path d="M18.5 8v6M15.5 11h6" />
</svg>
);
}
+35
View File
@@ -24,6 +24,7 @@ export const en = {
"auth.registerTitle": "Create account",
"auth.handle": "Handle",
"auth.handlePlaceholder": "e.g. alice",
"auth.localpartPlaceholder": "alice",
"auth.handleHint": "3–30 chars: a–z, 0–9, . _ -",
"auth.password": "Password",
"auth.passwordHint": "at least 8 characters",
@@ -511,6 +512,29 @@ export const en = {
"admin.registrationOff": "Closed",
"admin.registrationHelp":
"While closed, the signup page is hidden and the server refuses new accounts.",
"admin.invitations": "Allow invitations",
"admin.invitationsOn": "On",
"admin.invitationsOff": "Off",
"admin.invitationsHelp":
"Each user can invite 2 friends from the sidebar. An invitation link signs up one person, even while registration is closed, and expires after 14 days (unused ones are given back).",
"admin.inviteMail": "Invitation email",
"admin.inviteConfigure": "Configure…",
"admin.inviteMailHelp":
"Sent through the Outgoing system mail account (on the right of the panel), which must be allowed to send from the sender address. Replies go to the user who sent the invitation.",
"admin.inviteFrom": "Sender address",
"admin.inviteFromHint": "Empty: the placeholder address shown.",
"admin.inviteSubject": "Subject",
"admin.inviteTemplateDefault": "Using the built-in template.",
"admin.inviteTemplateCustom": "Using your uploaded template.",
"admin.inviteUpload": "Upload HTML template…",
"admin.inviteDownload": "Download template",
"admin.inviteReset": "Back to the built-in template",
"admin.inviteResetConfirm": "Remove your template and use the built-in one again?",
"admin.invitePlaceholders": "Placeholders:",
"admin.invitePlaceholdersHelp":
"The template must contain {{invite_link}} or {{invite_button}}. The subject can use the names too. Images written into the HTML are sent inside the email.",
"admin.invitePreview": "Preview with sample names —",
"admin.inviteTestSent": "Test invitation sent (its link is a dummy).",
"admin.defaultQuota": "Default quota for new users",
"admin.quotaHelp":
"Applies to accounts created from now on. Existing mailboxes keep the quota they were given.",
@@ -658,6 +682,17 @@ export const en = {
"Waiting for your browser's permission. If it didn't ask, click the icon at the left of the address bar and set Notifications to Allow — they turn on by themselves then.",
"push.failed": "Couldn't turn notifications on.",
"install.entry": "Install app",
"invite.entry": "Invite a friend",
"invite.title": "Invite a friend",
"invite.help": "We'll email them a link to create an account. You have {count} invitations left; each link works once.",
"invite.help_one": "We'll email them a link to create an account. This is your last invitation; the link works once.",
"invite.name": "Friend's name",
"invite.email": "Friend's email address",
"invite.send": "Send invitation",
"invite.sending": "Sending…",
"invite.sent": "Invitation sent to {name}",
"invite.welcome": "Hi {name} — {inviter} invited you. Choose your address and password.",
"invite.linkInvalid": "This invitation has already been used or has expired.",
"install.title": "Install Ultimo",
"install.stepsIos":
"1. Tap the Share button (the square with an arrow) in Safari's toolbar.\n2. Choose Add to Home Screen.\n3. Tap Add. Ultimo opens from your Home Screen like an app, and can show notifications about new mail.",
+23 -2
View File
@@ -2,6 +2,8 @@
// (SPEC §10.3) and includes cookies for the session (SPEC §6.1).
import type {
AdminInviteTemplate,
Invitations,
AppPassword,
AccountDetail,
AdminMail,
@@ -136,6 +138,25 @@ export const api = {
apiFetch<AdminMail>("/admin/mail", { method: "PUT", body: JSON.stringify(body) }),
testAdminMail: (to: string) =>
apiFetch<{ status: string }>("/admin/mail/test", { method: "POST", body: JSON.stringify({ to }) }),
adminInviteTemplate: () => apiFetch<AdminInviteTemplate>("/admin/invitations/template"),
// PUT merges; an empty string goes back to the built-in one.
saveAdminInviteTemplate: (body: { subject?: string; html?: string; from_address?: string }) =>
apiFetch<AdminInviteTemplate>("/admin/invitations/template", { method: "PUT", body: JSON.stringify(body) }),
previewInvite: (subject: string, html: string) =>
apiFetch<{ subject: string; html: string }>("/admin/invitations/preview", {
method: "POST",
body: JSON.stringify({ subject, html }),
}),
testInvite: (to: string) =>
apiFetch<{ status: string }>("/admin/invitations/test", { method: "POST", body: JSON.stringify({ to }) }),
invitations: () => apiFetch<Invitations>("/invitations"),
sendInvitation: (name: string, email: string) =>
apiFetch<Invitations>("/invitations", { method: "POST", body: JSON.stringify({ name, email }) }),
invitationCheck: (token: string) =>
apiFetch<{ valid: boolean; invitee_name: string; inviter_name: string }>("/auth/invitation/check", {
method: "POST",
body: JSON.stringify({ token }),
}),
passwordResetRequest: (handle: string) =>
apiFetch<{ status: string }>("/auth/password/reset-request", {
method: "POST",
@@ -157,10 +178,10 @@ export const api = {
body: JSON.stringify({ password, recovery_email }),
}),
registrationStatus: () => apiFetch<RegistrationStatus>("/auth/registration"),
register: (localpart: string, password: string) =>
register: (localpart: string, password: string, invite?: string) =>
apiFetch<UserOut>("/auth/register", {
method: "POST",
body: JSON.stringify({ localpart, password }),
body: JSON.stringify({ localpart, password, ...(invite ? { invite } : {}) }),
}),
// rememberDevice: with a correct 2FA code, don't ask this browser for one again.
login: (handle: string, password: string, totp?: string, rememberDevice = false) =>
+24
View File
@@ -46,12 +46,36 @@ export interface AdminSettings {
registration_open: boolean;
/** Quota given to users created from now on; existing mailboxes keep theirs. */
default_quota_bytes: number;
/** Users may send friends sign-up invitations. */
invitations_enabled: boolean;
}
/** The invitation email (admin panel). Empty strings: the built-in ones. */
export interface AdminInviteTemplate {
subject: string;
html: string;
from_address: string;
default_subject: string;
default_html: string;
default_from: string;
placeholders: string[];
}
/** Invite a friend: what the sidebar entry and its dialog show. */
export interface Invitations {
/** Switched on by the administrator, and the server can send email. */
available: boolean;
remaining: number;
per_user: number;
sent: { invitee_name: string; invitee_email: string; created_at: string; used: boolean; expired: boolean }[];
}
export interface RegistrationStatus {
open: boolean;
/** No users yet: the next signup creates the administrator. */
bootstrap: boolean;
/** Every handle is localpart@domain. */
domain: string;
}
export interface ThreadSummary {
+4 -7
View File
@@ -2,12 +2,10 @@ import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Link } from "react-router-dom";
import { HandleInput } from "../components/HandleInput";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
const inputClass =
"w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none";
// "Forgot password?": ask for the user name and have a reset link mailed to the
// account's recovery address. The answer is the same whether or not the account
// exists — the page never tells a stranger which user names are real.
@@ -31,11 +29,10 @@ export function ForgotPassword() {
if (handle.trim()) request.mutate();
}}
>
<input
className={inputClass}
placeholder={t("auth.handlePlaceholder")}
<HandleInput
placeholder={t("auth.localpartPlaceholder")}
value={handle}
onChange={(e) => setHandle(e.target.value)}
onChange={setHandle}
autoFocus
/>
<button
+4 -4
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { HandleInput } from "../components/HandleInput";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
@@ -44,11 +45,10 @@ export function Login() {
mutation.mutate();
}}
>
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
placeholder={t("auth.handlePlaceholder")}
<HandleInput
placeholder={t("auth.localpartPlaceholder")}
value={handle}
onChange={(e) => setHandle(e.target.value)}
onChange={setHandle}
disabled={needTotp}
autoFocus
/>
+3
View File
@@ -28,6 +28,7 @@ import { DraftsList, ScheduledList } from "../components/list/DraftsList";
import { Icon } from "../components/Icon";
import { InstallAppEntry } from "../components/InstallApp";
import { MessageList } from "../components/list/MessageList";
import { InviteFriendEntry } from "../components/InviteFriend";
import { Resizer } from "../components/Resizer";
import { ThemeToggle } from "../components/ThemeToggle";
import { EmptyIllustration } from "../components/EmptyIllustration";
@@ -1202,6 +1203,8 @@ export function MailLayout() {
<InstallAppEntry onDone={() => setDrawerOpen(false)} />
</div>
)}
{/* Above the divider over Settings; gone once the invitations are used. */}
<InviteFriendEntry collapsed={collapsed} onDone={() => setDrawerOpen(false)} />
<div className="border-t border-app-border p-2 text-[15px]">
<Link
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
+36 -7
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { HandleInput } from "../components/HandleInput";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
@@ -19,8 +20,21 @@ export function Register() {
// no form at all. The server enforces this too — this is only the UI.
const status = useQuery({ queryKey: ["registration"], queryFn: api.registrationStatus });
// A friend's invitation (/register#invite=…): opens sign-up even while it is
// closed. The token rides in the fragment, which never reaches a server log.
const [invite] = useState(
() => new URLSearchParams(window.location.hash.replace(/^#/, "")).get("invite") ?? "",
);
const inviteCheck = useQuery({
queryKey: ["invitation", invite],
queryFn: () => api.invitationCheck(invite),
enabled: invite !== "",
staleTime: Infinity,
});
const invited = inviteCheck.data?.valid === true;
const mutation = useMutation({
mutationFn: () => api.register(localpart, password),
mutationFn: () => api.register(localpart, password, invited ? invite : undefined),
onSuccess: (user) => {
queryClient.setQueryData(["me"], user);
// The first signup closes registration behind itself; drop the cached answer.
@@ -38,7 +52,7 @@ export function Register() {
</p>
);
if (status.isPending) {
if (status.isPending || (invite && inviteCheck.isPending)) {
return (
<div className="mx-auto mt-24 max-w-sm px-4">
<p className="text-sm text-app-muted">{t("auth.checking")}</p>
@@ -46,9 +60,16 @@ export function Register() {
);
}
if (status.data && !status.data.open) {
const inviteGone = invite !== "" && !invited && (
<p className="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
{t("invite.linkInvalid")}
</p>
);
if (status.data && !status.data.open && !invited) {
return (
<div className="mx-auto mt-24 max-w-sm px-4">
{inviteGone}
<h1 className="mb-3 text-2xl font-semibold">{t("auth.registrationClosedTitle")}</h1>
<p className="text-sm text-app-muted">{t("auth.registrationClosed")}</p>
{signInLink}
@@ -60,9 +81,18 @@ export function Register() {
return (
<div className="mx-auto mt-24 max-w-sm px-4">
{inviteGone}
<h1 className="mb-3 text-2xl font-semibold">
{bootstrap ? t("auth.firstUserTitle") : t("auth.registerTitle")}
</h1>
{invited && (
<p className="mb-5 text-sm text-app-muted">
{t("invite.welcome", {
name: inviteCheck.data?.invitee_name ?? "",
inviter: inviteCheck.data?.inviter_name ?? "",
})}
</p>
)}
{bootstrap && <p className="mb-5 text-sm text-app-muted">{t("auth.firstUserHint")}</p>}
<form
className="space-y-3"
@@ -71,11 +101,10 @@ export function Register() {
mutation.mutate();
}}
>
<input
className={inputClass}
placeholder={t("auth.handlePlaceholder")}
<HandleInput
placeholder={t("auth.localpartPlaceholder")}
value={localpart}
onChange={(e) => setLocalpart(e.target.value)}
onChange={setLocalpart}
autoFocus
/>
<input
+5 -3
View File
@@ -23,10 +23,12 @@ export default defineConfig({
proxy: {
"/api": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
// Host kept as the browser sent it: the API's cross-site check
// (app/security/cross_site.py) compares the page's Origin with it, and
// a WebSocket upgrade carries only Origin (no Sec-Fetch-Site, and the
// proxy adds no X-Forwarded-Host to upgrades).
changeOrigin: false,
ws: true,
// X-Forwarded-Host: the API's cross-site check (app/security/cross_site.py)
// compares the page's Origin with the name the browser used.
xfwd: true,
},
// CalDAV / CardDAV (SPEC Appendix E), for trying clients against dev.