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>
133 lines
5.0 KiB
Python
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
|