Files
adminandClaude Opus 5.5 8937c28028 Personalisierung per Einladung statt Aktivierungscode (ADR-0041, 24.10.2026)
Geräte-Info: Adressfeld (Domänenliste, nur mit Merkmal), erneut senden,
zurückziehen. Öffentlich: Einladungsseite mit Namensvorschlag, Stand-Seite
mit Selbstaktualisierung, Hinweis für den Kiosk älterer Rollen.
Code-Formular und Bestätigungsseiten entfernt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:52:15 +02:00

309 lines
12 KiB
Python

import asyncio
import logging
import os
import smtplib
import ssl
import sys
import time
import uuid
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formatdate, make_msgid, parseaddr
from pathlib import Path
from email_validator import EmailNotValidError, validate_email
from db import get_database_connection
# Mailversand der Plattform (ADR-0041, 12.10.2026). Aufrufer tragen eine
# Mail mit einreihen() in die Tabelle mail_ausgang ein, die Versandschleife
# (in app.py beim Start angestoßen) liefert sie per SMTP mit Anmeldung beim
# Mailanbieter der Domain ein. anode verschickt nie direkt an Empfänger -
# Mails von einer Heimanschluss-Adresse landen im Spam.
#
# Bewusst nur Standardbibliothek (smtplib), keine neue Abhängigkeit. Der
# Dienst läuft als einzelner uvicorn-Prozess (siehe auth.py), es gibt also
# genau eine Versandschleife; FOR UPDATE SKIP LOCKED schützt trotzdem
# gegen einen parallel laufenden scripts/testmail.py.
log = logging.getLogger("kundenplattform.mail")
if not log.handlers:
# Die Anwendung konfiguriert sonst kein Logging; ohne eigenen Handler
# gingen INFO-Meldungen (Trockenlauf!) verloren. stderr landet im Journal.
log.addHandler(logging.StreamHandler(sys.stderr))
log.setLevel(logging.INFO)
# Wartezeiten vor dem 2., 3. und 4. Versuch. Danach gilt die Mail als
# fehlgeschlagen. Zusammen gut 20 Minuten - passt in die Stundenfrist der
# Links für "Passwort vergessen?".
WIEDERHOLUNG_NACH_SEKUNDEN = (60, 300, 900)
SCHLEIFEN_INTERVALL_SEKUNDEN = 10
FEHLER_PAUSE_SEKUNDEN = 300
SMTP_TIMEOUT_SEKUNDEN = 30
# Die Tabelle enthält Empfängeradressen (personenbezogen) - alte Einträge
# werden gelöscht.
AUFBEWAHRUNG_TAGE = 90
AUFRAEUMEN_INTERVALL_SEKUNDEN = 60 * 60
@dataclass
class Konfiguration:
modus: str
host: str
port: int
benutzer: str
absender: str
antwort_an: str
passwort_datei: str
def _konfiguration() -> Konfiguration:
"""
Bei jedem Aufruf frisch aus der Umgebung gelesen (nicht beim Import wie
in db.py/encryption.py), damit Tests und scripts/testmail.py den Modus
umschalten können.
KUNDENPLATTFORM_MAIL_MODUS: "trockenlauf" (Default - schreibt die Mail
ins Protokoll statt sie zu verschicken) oder "smtp".
Das Passwort steht nie in der Umgebung, sondern in einer Datei:
systemd-Credential "mail_passwort" (siehe kundenplattform.service) oder
ausdrücklich KUNDENPLATTFORM_MAIL_PASSWORT_DATEI.
"""
passwort_datei = os.environ.get("KUNDENPLATTFORM_MAIL_PASSWORT_DATEI", "")
if not passwort_datei and os.environ.get("CREDENTIALS_DIRECTORY"):
passwort_datei = os.path.join(os.environ["CREDENTIALS_DIRECTORY"], "mail_passwort")
return Konfiguration(
modus=os.environ.get("KUNDENPLATTFORM_MAIL_MODUS", "trockenlauf"),
host=os.environ.get("KUNDENPLATTFORM_MAIL_HOST", ""),
port=int(os.environ.get("KUNDENPLATTFORM_MAIL_PORT", "587")),
benutzer=os.environ.get("KUNDENPLATTFORM_MAIL_BENUTZER", ""),
absender=os.environ.get("KUNDENPLATTFORM_MAIL_ABSENDER", ""),
antwort_an=os.environ.get("KUNDENPLATTFORM_MAIL_ANTWORT_AN", ""),
passwort_datei=passwort_datei,
)
def _als_text(wert):
# psycopg liefert TEXT-Spalten hier teils als bytes (siehe auth.py).
return wert.decode("utf-8") if isinstance(wert, bytes) else wert
def einreihen(empfaenger: str, betreff: str, text: str, zweck: str, bezug: str | None = None, conn=None) -> str:
"""
Trägt eine Mail zum Versand ein und gibt ihre ID zurück (für
mail_status()). Mit conn läuft der Eintrag in der Transaktion des
Aufrufers - die Mail geht dann nur raus, wenn auch dessen übrige
Änderungen festgeschrieben werden.
Wirft ValueError bei ungültiger Adresse oder Zeilenumbruch im Betreff.
"""
try:
adresse = validate_email(empfaenger.strip(), check_deliverability=False).normalized
except EmailNotValidError as fehler:
raise ValueError(f"Ungültige E-Mail-Adresse: {fehler}") from fehler
if "\n" in betreff or "\r" in betreff:
raise ValueError("Der Betreff darf keinen Zeilenumbruch enthalten.")
mail_id = uuid.uuid4()
sql = "INSERT INTO mail_ausgang (id, empfaenger, betreff, text, zweck, bezug) VALUES (%s, %s, %s, %s, %s, %s)"
werte = (mail_id, adresse, betreff, text, zweck, bezug)
if conn is not None:
with conn.cursor() as cur:
cur.execute(sql, werte)
else:
with get_database_connection() as eigene_verbindung:
with eigene_verbindung.cursor() as cur:
cur.execute(sql, werte)
return str(mail_id)
def mail_status(mail_id: str) -> dict | None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT status, versuche, letzter_fehler, created_at, gesendet_at
FROM mail_ausgang WHERE id = %s
""",
(mail_id,),
)
row = cur.fetchone()
if row is None:
return None
status, versuche, letzter_fehler, created_at, gesendet_at = row
return {
"status": _als_text(status),
"versuche": versuche,
"letzter_fehler": _als_text(letzter_fehler),
"created_at": created_at,
"gesendet_at": gesendet_at,
}
def _nachricht(konfiguration: Konfiguration, empfaenger: str, betreff: str, text: str) -> EmailMessage:
nachricht = EmailMessage()
nachricht["From"] = konfiguration.absender
nachricht["To"] = empfaenger
nachricht["Subject"] = betreff
nachricht["Date"] = formatdate(localtime=True)
nachricht["Message-ID"] = make_msgid(domain=parseaddr(konfiguration.absender)[1].rpartition("@")[2] or None)
# Kennzeichnet die Mail als automatisch erzeugt, damit
# Abwesenheitsnotizen nicht darauf antworten.
nachricht["Auto-Submitted"] = "auto-generated"
if konfiguration.antwort_an:
nachricht["Reply-To"] = konfiguration.antwort_an
nachricht.set_content(text)
return nachricht
def _per_smtp_senden(konfiguration: Konfiguration, nachricht: EmailMessage) -> None:
fehlend = [
name for name, wert in (
("KUNDENPLATTFORM_MAIL_HOST", konfiguration.host),
("KUNDENPLATTFORM_MAIL_BENUTZER", konfiguration.benutzer),
("KUNDENPLATTFORM_MAIL_ABSENDER", konfiguration.absender),
("Passwortdatei (Credential mail_passwort)", konfiguration.passwort_datei),
) if not wert
]
if fehlend:
raise RuntimeError("Mailversand nicht konfiguriert, es fehlt: " + ", ".join(fehlend))
passwort = Path(konfiguration.passwort_datei).read_text(encoding="utf-8").strip()
with smtplib.SMTP(konfiguration.host, konfiguration.port, timeout=SMTP_TIMEOUT_SEKUNDEN) as smtp:
smtp.starttls(context=ssl.create_default_context())
smtp.login(konfiguration.benutzer, passwort)
smtp.send_message(nachricht)
def ausgang_abarbeiten(limit: int = 20) -> int:
"""
Verschickt die fälligen Mails (höchstens limit) und gibt zurück, wie
viele bearbeitet wurden. Jede Mail läuft in einer eigenen Transaktion,
die Zeile bleibt während des Versands gesperrt.
"""
konfiguration = _konfiguration()
bearbeitet = 0
with get_database_connection() as conn:
while bearbeitet < limit:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, empfaenger, betreff, text, zweck, versuche
FROM mail_ausgang
WHERE status = 'wartet' AND naechster_versuch_at <= CURRENT_TIMESTAMP
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED
"""
)
row = cur.fetchone()
if row is None:
break
mail_id, empfaenger, betreff, text, zweck, versuche = row
empfaenger, betreff, text, zweck = (_als_text(w) for w in (empfaenger, betreff, text, zweck))
versuche += 1
status, fehler, wartezeit = _zustellen(konfiguration, empfaenger, betreff, text, zweck, versuche)
if status == "wartet":
cur.execute(
"""
UPDATE mail_ausgang
SET versuche = %s, letzter_fehler = %s,
naechster_versuch_at = CURRENT_TIMESTAMP + make_interval(secs => %s)
WHERE id = %s
""",
(versuche, fehler, wartezeit, mail_id),
)
else:
cur.execute(
"""
UPDATE mail_ausgang
SET status = %s, versuche = %s, letzter_fehler = %s, text = NULL,
gesendet_at = CASE WHEN %s = 'gesendet' THEN CURRENT_TIMESTAMP END
WHERE id = %s
""",
(status, versuche, fehler, status, mail_id),
)
conn.commit()
bearbeitet += 1
return bearbeitet
def _zustellen(konfiguration, empfaenger, betreff, text, zweck, versuche) -> tuple[str, str | None, int]:
"""Ein Zustellversuch. Gibt (neuer Status, Fehlertext, Wartezeit bis zum nächsten Versuch) zurück."""
if konfiguration.modus != "smtp":
log.info("Mail (Trockenlauf) an %s\nBetreff: %s\n%s\n---", empfaenger, betreff, text)
return "trockenlauf", None, 0
try:
_per_smtp_senden(konfiguration, _nachricht(konfiguration, empfaenger, betreff, text))
except smtplib.SMTPRecipientsRefused as fehler:
# Der Mailserver lehnt die Adresse ab - ein weiterer Versuch ändert
# daran nichts.
log.warning("Mail (%s) an %s abgelehnt: %s", zweck, empfaenger, fehler)
return "fehlgeschlagen", f"Empfänger abgelehnt: {fehler}", 0
except Exception as fehler: # noqa: BLE001 - jeder Fehler führt zur Wiederholung
if versuche > len(WIEDERHOLUNG_NACH_SEKUNDEN):
log.error("Mail (%s) an %s endgültig fehlgeschlagen nach %s Versuchen: %s", zweck, empfaenger, versuche, fehler)
return "fehlgeschlagen", str(fehler), 0
wartezeit = WIEDERHOLUNG_NACH_SEKUNDEN[versuche - 1]
log.warning("Mail (%s) an %s: Versuch %s fehlgeschlagen (%s), nächster in %s s", zweck, empfaenger, versuche, fehler, wartezeit)
return "wartet", str(fehler), wartezeit
log.info("Mail (%s) an %s verschickt.", zweck, empfaenger)
return "gesendet", None, 0
def aufraeumen() -> int:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM mail_ausgang WHERE created_at < CURRENT_TIMESTAMP - make_interval(days => %s)",
(AUFBEWAHRUNG_TAGE,),
)
return cur.rowcount
async def versand_schleife() -> None:
"""Läuft für die Lebensdauer des Dienstes (siehe lifespan in app.py)."""
zuletzt_aufgeraeumt = 0.0
while True:
try:
await asyncio.to_thread(ausgang_abarbeiten)
if time.monotonic() - zuletzt_aufgeraeumt > AUFRAEUMEN_INTERVALL_SEKUNDEN:
await asyncio.to_thread(aufraeumen)
zuletzt_aufgeraeumt = time.monotonic()
except Exception: # noqa: BLE001 - die Schleife darf nie enden
# Typisch: Datenbank nicht erreichbar oder Migration 0009 fehlt.
# Lange Pause, damit das Journal nicht vollläuft.
log.exception("Versandschleife: Durchlauf fehlgeschlagen, nächster in %s s.", FEHLER_PAUSE_SEKUNDEN)
await asyncio.sleep(FEHLER_PAUSE_SEKUNDEN)
continue
await asyncio.sleep(SCHLEIFEN_INTERVALL_SEKUNDEN)