Files
webmail-client/backend/app/identity/mailer.py
T
lukasandClaude Opus 5.5 165442d897 Logo in system mail; "don't ask for the 2FA code on this device"
System mail: the password-reset email (and the admin panel's test email) now
opens with the Ultimo logo, centered. It is a PNG rendered from
icons/ultimo-logo.svg and embedded in the message (multipart/related, cid:),
so it shows where remote images are blocked and in clients that do not
render SVG. The PNG is listed in package-data so the Docker image carries it.

Trusted devices: a 2FA sign-in offers "Don't ask for the code on this device
in future". Ticked with a correct code, the browser gets its own HttpOnly
trusted-device cookie (token in Redis, 90 days, renewed on use); later
sign-ins to the same account from that browser need only the password. Other
accounts and browsers are unaffected. The trust is revoked on a password
reset, when 2FA is turned off, and when an admin blocks or deletes the user;
signing out keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:02:44 +02:00

133 lines
5.0 KiB
Python

"""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
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 html as html_lib
import logging
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr, make_msgid
from functools import cache
from pathlib import Path
import aiosmtplib
from app.models import AppSettings
from app.security.crypto import decrypt_secret
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'<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>'
)
@cache
def _logo() -> bytes:
return _LOGO_FILE.read_bytes()
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) -> None:
msg = EmailMessage()
msg["From"] = formataddr((SENDER_NAME, cfg.sender))
msg["To"] = to
msg["Subject"] = subject
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}>")
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,
)
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'<!doctype html>\n<html><body style="{body}">\n{LOGO_HTML}\n'
f"<p>Someone asked to change the password of the Ultimo Email account <b>{h}</b>.</p>\n"
"<p>To choose a new password, open this link within 30 minutes:</p>\n"
f'<p><a href="{u}" style="{button}">Change password</a></p>\n'
f'<p style="{muted}">Or paste this address into your browser:<br>{u}</p>\n'
f'<p style="{muted}">The link works once. If you did not ask for this, ignore this '
"email — your password stays as it is.</p>\n</body></html>\n"
)
return subject, text, html