"""Outgoing system mail (SPEC ยง6.1): mail the server sends on its own behalf. 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 accounts this connection is not held to the SSRF guard (it may well be a relay 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, 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" _TIMEOUT = 20.0 # The logo travels inside the message (multipart/related, referenced by cid:), # not as a link: it shows even where remote images are blocked, and it is a PNG # 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_HTML = ( f'

Ultimo Email

' ) @cache 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.""" @dataclass(frozen=True) class MailerConfig: host: str port: int security: str # starttls | ssl | none username: str password: str sender: str public_url: str def mailer_config(row: AppSettings) -> MailerConfig | None: """The usable configuration, or None while the admin has not set one up.""" if not (row.smtp_host and row.smtp_from and row.public_url): return None password = decrypt_secret(row.smtp_secret_ct).decode() if row.smtp_secret_ct else "" return MailerConfig( host=row.smtp_host, port=row.smtp_port, security=row.smtp_security, username=row.smtp_username, password=password, sender=row.smtp_from, public_url=row.public_url.rstrip("/"), ) 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, 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: html_part = msg.get_body(("html",)) if isinstance(html_part, EmailMessage): 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, hostname=cfg.host, port=cfg.port, username=cfg.username or None, password=cfg.password or None, 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 def reset_mail(link: str, handle: str) -> tuple[str, str, str]: """(subject, text, html) of the password-reset message.""" subject = "Reset your Ultimo Email password" text = ( f"Someone asked to change the password of the Ultimo Email account {handle}.\n\n" f"To choose a new password, open this link within 30 minutes:\n\n{link}\n\n" "The link works once. If you did not ask for this, ignore this email โ€” " "your password stays as it is.\n" ) h, u = html_lib.escape(handle), html_lib.escape(link, quote=True) 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" ) body = "font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#1f2937;line-height:1.5" html = ( f'\n\n{LOGO_HTML}\n' f"

Someone asked to change the password of the Ultimo Email account {h}.

\n" "

To choose a new password, open this link within 30 minutes:

\n" f'

Change password

\n' f'

Or paste this address into your browser:
{u}

\n' f'

The link works once. If you did not ask for this, ignore this ' "email โ€” your password stays as it is.

\n\n" ) return subject, text, html