111 lines
4.0 KiB
Python
111 lines
4.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
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
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")
|
||
|
|
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'
|
||
|
|
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
|