Files
lukasandClaude Opus 5.5 463f96ad57 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>
2026-10-15 19:34:31 +02:00

241 lines
8.9 KiB
Python

"""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)
)