"""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"""
{{{{logo}}}}Hi {{{{invitee_name}}}},
{{{{inviter_name}}}} invites you to Ultimo Email — private email, calendar and contacts in one place.
{{{{invite_button}}}}
Or paste this address into your browser:
{{{{invite_link}}}}
The invitation works once and expires in 14 days. If you weren't expecting it, you can ignore this email.
""" 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'{esc_link}', "invite_button": f'Accept the invitation', "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"