Files
webmail-client/backend/app/identity/mailer.py
T

133 lines
5.0 KiB
Python
Raw Normal View History

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