170 lines
6 KiB
Python
170 lines
6 KiB
Python
"""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 <style>, so
|
|
# the palette is inlined via the f-string constants above.
|
|
message.add_alternative(_page(headline, intro, button_label, button_url, note), subtype="html")
|
|
return message
|
|
|
|
|
|
def send_magic_link(to_email: str, link: str) -> bool:
|
|
"""Mail the login link. Returns False (logs instead) when SMTP is unset.
|
|
|
|
Never raises: callers invoke it after committing (approve/bootstrap),
|
|
so a delivery failure must not turn into a 500 — the token stays valid
|
|
and the admin sees ``email_sent: false``.
|
|
|
|
The plaintext link only ever appears here: service/api logging must
|
|
never include tokens or session ids (see test_auth no-leak test).
|
|
"""
|
|
if not settings.smtp_host:
|
|
# Never log the link itself: without SMTP this fallback fires in
|
|
# every environment, and logs are rarely as protected as mail.
|
|
logger.info("SMTP not configured; dropping magic link for %s", to_email)
|
|
return False
|
|
message = _branded_mail(
|
|
subject="Dein Login-Link für Kirche Einfach",
|
|
to_email=to_email,
|
|
headline="Anmeldung bei Kirche Einfach",
|
|
intro=(
|
|
"Klicke auf den Button, um dich anzumelden. Der Link ist "
|
|
"15 Minuten gültig und funktioniert nur einmal."
|
|
),
|
|
button_label="Jetzt anmelden",
|
|
button_url=link,
|
|
note=(
|
|
"Falls du den Button nicht nutzen kannst, kopiere den Link "
|
|
"aus der Textversion dieser E-Mail in deinen Browser. "
|
|
"Diese E-Mail wurde angefordert — wenn du das warst, ist "
|
|
"alles gut; sonst kannst du sie ignorieren."
|
|
),
|
|
)
|
|
try:
|
|
_send(message)
|
|
except Exception: # noqa: BLE001 -- never-raise contract: delivery must not 500
|
|
logger.warning("magic-link delivery failed for %s", to_email)
|
|
return False
|
|
logger.info("magic link mailed to %s", to_email)
|
|
return True
|
|
|
|
|
|
def send_access_notification(request_id: str, name: str, email: str) -> bool:
|
|
"""Notify CONTACT_TO about a new access request (best effort)."""
|
|
if not settings.smtp_host or not settings.contact_to:
|
|
logger.info("new access request %s from %s <%s>", request_id, name, email)
|
|
return False
|
|
message = _branded_mail(
|
|
subject=f"Neue Zugangsanfrage: {name}",
|
|
to_email=settings.contact_to,
|
|
headline="Neue Zugangsanfrage",
|
|
intro=(
|
|
f"{name} ({email}) hat Zugang angefordert und wartet auf Freigabe in der Verwaltung."
|
|
),
|
|
button_label="In der Verwaltung prüfen",
|
|
button_url=f"{settings.frontend_url.rstrip('/')}/app/admin",
|
|
note="Diese Benachrichtigung geht an das Kontakt-Postfach.",
|
|
)
|
|
try:
|
|
_send(message)
|
|
except Exception: # noqa: BLE001 -- best-effort notification, never raises
|
|
logger.warning("access-request notification failed for %s", request_id)
|
|
return False
|
|
return True
|