"""Magic-link delivery. SMTP when configured, dev log fallback otherwise. Mail design follows the web branding (see frontend/src/theme.ts): kirchenblau header, serif headline, white card on paper background. Email-safe by construction: table layout, inline styles only, system fonts, no external images (many clients block them / track opens). """ from __future__ import annotations import logging from email.message import EmailMessage from pathlib import Path from jinja2 import Template from app.settings import settings logger = logging.getLogger(__name__) # Brand palette (mirrors frontend/src/theme.ts kirchenblau). BLUE = "#1d4a79" BLUE_LIGHT = "#eef4fb" INK = "#162f4c" MUTED = "#5b6b7c" _TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "magic_link.html" _template_cache: Template | None = None def _template() -> Template: global _template_cache if _template_cache is None: _template_cache = Template(_TEMPLATE_PATH.read_text(encoding="utf-8")) return _template_cache def magic_link(token: str) -> str: return f"{settings.frontend_url.rstrip('/')}/login?token={token}" def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str: """Branded HTML mail body (jinja template, byte-identical to the old f-string).""" return _template().render( title=title, intro=intro, button_label=button_label, button_url=button_url, note=note, blue=BLUE, blue_light=BLUE_LIGHT, ink=INK, muted=MUTED, ) def _send(message: EmailMessage) -> None: """Deliver via fastapi-mail (same never-raise contract at call sites).""" import asyncio from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType config = ConnectionConfig( MAIL_USERNAME=settings.smtp_username, MAIL_PASSWORD=settings.smtp_password, MAIL_FROM=settings.mail_from or settings.contact_to or message["To"], MAIL_PORT=settings.smtp_port, MAIL_SERVER=settings.smtp_host, MAIL_STARTTLS=True, MAIL_SSL_TLS=False, USE_CREDENTIALS=bool(settings.smtp_username), ) html = message.get_body(preferencelist=("html",)) plain = message.get_body(preferencelist=("plain",)) outgoing = MessageSchema( subject=str(message["Subject"]), recipients=[str(message["To"])], body=plain.get_content() if plain else "", html=html.get_content() if html else "", subtype=MessageType.html, ) asyncio.run(FastMail(config).send_message(outgoing)) def _branded_mail( subject: str, to_email: str, headline: str, intro: str, button_label: str, button_url: str, note: str, ) -> EmailMessage: """Text + branded HTML alternative; the link is a real button.""" message = EmailMessage() message["Subject"] = subject message["To"] = to_email message["From"] = settings.mail_from or settings.contact_to or to_email if settings.contact_to: message["Reply-To"] = settings.contact_to message.set_content(f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n") # Email-safe HTML: tables + inline CSS; some clients strip