Invite a friend; handle field shows the domain; system mail fixes
- Invitations: each user can invite two friends by email (sign-up works through the link even while registration is closed); admin-editable template, subject and sender. - Invitation sender defaults to the system-mail From address, which the SMTP account is allowed to send as (was postmaster@APP_DOMAIN, refused with 553 when the two differ). - Sign-in, sign-up and forgot-password fields show the fixed @domain after the typed name; the registration status reports it. - System mail gets a Date header; the invitation's plain-text part keeps its paragraphs and line breaks. - SMTP EHLO uses SMTP_HELO_NAME / APP_DOMAIN, not the container name. - DNS recommendations for the mail domains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""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
|
||||
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
|
||||
@@ -10,19 +11,26 @@ 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, make_msgid
|
||||
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"
|
||||
@@ -33,9 +41,9 @@ _TIMEOUT = 20.0
|
||||
# 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_CID = "ultimo-logo@ultimo.email"
|
||||
LOGO_HTML = (
|
||||
f'<p style="text-align:center;margin:0 0 24px"><img src="cid:{_LOGO_CID}" '
|
||||
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>'
|
||||
)
|
||||
|
||||
@@ -45,6 +53,10 @@ 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."""
|
||||
|
||||
@@ -76,19 +88,49 @@ def mailer_config(row: AppSettings) -> MailerConfig | None:
|
||||
)
|
||||
|
||||
|
||||
async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None:
|
||||
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, cfg.sender))
|
||||
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:
|
||||
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}>")
|
||||
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,
|
||||
@@ -99,6 +141,7 @@ async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: s
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user